Hace quince días empezamos con la serie ¿cómo crear un videojuego en Go?, la que sin duda creemos que no os dejará indiferentes (ya veréis cuándo esté terminada, ¡se vienen sorpresas!). Sin embargo, no os queremos agobiar con los videojuegos, ni tampoco queremos perder la tracción de aquellos seguidores que no tienen un especial interés por los videojuegos.
Por ello, hoy volvemos a un tema que siempre genera especial interés: el testing.
No solo de fuzzing vive el gopher
Como ya os comentamos hace un mes en este artículo, el fuzzing es un técnica de testing automatizada que consiste en proporcionar datos inválidos, inesperados o aleatorios como datos de entrada de un programa. Sin embargo, como ya vimos, el fuzzing no es la solución a todos nuestros problemas: esta técnica no es más que un complemento a otras técnicas más comunes, como los tests unitarios o los tests de integración. Al final, el objetivo es (intentar) garantizar que las aplicaciones que desarrollamos hacen lo que queremos que hagan, con la menor cantidad de errores (bugs) possible.
Con dicho objetivo en nuestro punto de mira, hoy os queremos presentar otra técnica de testing que nos va a permitir iterar aún más en la robustez de nuestras aplicaciones: el property-based testing. De la traducción literal del inglés podemos deducir que esta técnica está basada en “propiedades”, pero exactamente ¿qué significa eso?
¿Qué entendemos por “propiedades”?
Para definir qué entendemos por “propiedades”, lo primero que podemos hacer es definir los tests como los hemos entendido hasta ahora. Si pensamos en los tests unitarios “tradicionales”, lo que solemos hacer es definir una entrada y la salida esperada para dicha entrada (o un conjunto de entradas y salidas en el caso de los table-driven tests o los data providers en otros lenguajes), de forma que, tras llamar a la función que estamos testeando, podremos asercionar que el resultado obtenido es el esperado.
Por ejemplo, suponiendo que tenemos una función func Add(x, y int) int
que devuelve la suma de x
+ y
podríamos tener
algo como:
package math
import (
"testing"
)
func Add(x, y int) int {
return x + y
}
func TestAdd(t *testing.T) {
x, y := 2, 4
expected := 6
got := Add(x, y)
if expected != got {
t.Errorf("Add(%d, %d) failed, expected: %d, got: %d\n", x, y, expected, got)
}
}
Sin embargo, cuándo estamos empezando en el mundo del testing, estas situaciones nos suelen llevar a la siguiente pregunta:
- ¿Cómo podemos garantizar que nuestra función es válida para todo el rango de valores de entrada posibles?
Pues, si bien es cierto que en este caso parece que no hay error posible (esperemos que la implementación del operador
+
no tenga errores), en implementaciones más complejas, esto puede no parecer tan claro. En estas situaciones, lo que
solemos intentar es escribir un test para cada uno de los casos extremos. Es decir, en este caso podríamos probar que
la función devuelve el valor esperado con entradas positivas, negativas y con el valor cero.
Sin embargo, para ir un paso más allá, lo que podríamos cambiar es el enfoque de nuestros tests. De modo que, en lugar de preguntar: ¿cuál es el resultado esperado de esta función dadas estas entradas de ejemplo?, podríamos preguntarnos: ¿cuál es la propiedad de las salidas y la función que no cambia con las diferentes entradas dadas?
Por ejemplo, si nos hacemos dicha pregunta para la función Add
que definimos anteriormente, podríamos llegar a decir que
una de las propiedades de la función es que a + b
es siempre igual a b + a
. Es decir, en términos matemáticos podríamos
decir que dicha función cumple la propiedad conmutativa.
Identificado esto, ahora podríamos escribir el siguiente test:
func TestAdd(t *testing.T) {
// a function that checks for commutativity
comm := func(fn func(a, b int) int) bool {...}
if !comm(Add) {
t.Error("Add's implementation is not commutative")
}
}
dónde la función que hemos asignado a la variable comm
sería una función que dada la función Add
como argumento
de entrada, comprobaría que si intercambiamos el orden de los atributos de dicha función, no se altera el resultado.
Esto, de algún modo, podríamos llamarlo property-based testing, pues, al fin y al cabo, estamos haciendo eso: comprobar que nuestras funciones cumplen determinadas propiedades. Sin embargo, es probable que estéis pensando que esto puede resultar demasiado manual y engorroso. De hecho, seguimos teniendo, en parte, el mismo problema: pues solo vamos a comprobar que dicha propiedad se cumple para las entradas dadas. Así que, veamos como podemos mejorar un poco esta propuesta.
Property-based testing en Go
Lo cierto es que, a pesar de que, como estamos viendo, esta técnica puede ser aplicada en lenguajes imperativos, su origen lo encontramos en los lenguajes funcionales, dónde la pureza de las funciones es una característica que favorece mucho la aplicación de dicha técnica.
De hecho, una de las librerías más populares de property-based testing es la librería QuickCheck,
de Haskell, lenguaje de programación funcional por excelencia. Go, en su lugar,
no podia ser menos, así que si investigamos un poco podemos encontrar el paquete quick
dentro del contexto testing
.
Y, por casualidad (o no), el paquete "testing/quick"
tiene un método Check
.
Así que, ya nos podemos poner manos a la obra:
import "testing/quick"
func TestAdd(t *testing.T) {
// property test
comm := func(a, b int) bool {
if Add(a, b) != Add(b, a) {
return false
}
return true
}
if err := quick.Check(comm, nil); err != nil {
t.Error(err)
}
}
Como podéis ver, de un modo similar al planteado anteriormente, hemos definido una función comm
que va a comprobar la
propiedad conmutativa de nuestro método Add
, sin embargo, en esta ocasión, la firma del método es algo distinta. La
magia aquí surge al llamar al método quick.Check
: dicho método llamará a la función comm
múltiples veces con argumentos
aleatorios, de forma que podremos garantizar dicha propiedad para nuestra función.
Configurando nuestro “quick.Check”
Si os habéis fijado, en la anterior llamada al método quick.Check
pasamos como segundo argumento un nil
. Eso es
porqué dicho método nos permite pasar un argumento de configuración, que se corresponde con la siguiente definición:
type Config struct {
// MaxCount sets the maximum number of iterations.
// If zero, MaxCountScale is used.
MaxCount int
// MaxCountScale is a non-negative scale factor applied to the
// default maximum.
// A count of zero implies the default, which is usually 100
// but can be set by the -quickchecks flag.
MaxCountScale float64
// Rand specifies a source of random numbers.
// If nil, a default pseudo-random source will be used.
Rand *rand.Rand
// Values specifies a function to generate a slice of
// arbitrary reflect.Values that are congruent with the
// arguments to the function being tested.
// If nil, the top-level Value function is used to generate them.
Values func([]reflect.Value, *rand.Rand)
}
Es decir, podremos determinar el número de iteraciones que realiza dicho método para considerar que la función cumple con la propiedad definida, y además podremos parametrizar la aleatoriedad de los valores de entrada, de forma que podamos hacer dichos tests sin perder la reproducibilidad de los mismos.
Generando valores aleatorios para nuestros tipos
Bien, seguramente estáis pensando que generar números aleatorios es algo trivial, sin embargo, no sucede lo mismo
cuándo nuestra función espera custom types (aquellos tipos definidos con la directiva type
).
Pues esa es precisamente la razón por la que el paquete en cuestión define la siguiente interfaz:
type Generator interface {
// Generate returns a random instance of the type on which it is a
// method using the size as a size hint.
Generate(rand *rand.Rand, size int) reflect.Value
}
De forma que lo único que tenemos que hacer es que nuestro tipo implemente el método Generate
. Veamos un ejemplo
sencillo para un hipotético tipo Point
que podríamos usar para representar las coordenadas de nuestro juego:
func (Point) Generate(r *rand.Rand, size int) reflect.Value {
p := Point{}
p.x = rand.Int()
p.y = rand.Int()
return reflect.ValueOf(p)
}
Entonces, ¿qué es el property-based testing?
Ahora que ya hemos definido lo que entendemos por “propiedades”, ya deberíamos ser capaces de identificar que tests son basados en ellas y cuáles no. Es decir, los property-based tests tienen que ver con la comprobación de que una función satisface las propiedades especificadas, a través de una amplia variedad de entradas, donde dichas propiedades son generalmente nociones abstractas de algo que es invariante.
De hecho, el invariante es precisamente un término comúnmente usado en las definiciones formales de los ejercicios de programación, por ejemplo en la programación de competición.
El PBT es solo una técnica más
Por supuesto, como ya dijimos al principio, lo que buscamos no es otra cosa que añadir una técnica más a nuestra lista de de recursos disponibles para garantizar la exactitud y la robustez de nuestras aplicaciones, pero esta técnica no es siempre posible (especialmente con funciones impuras) o probablemente ya la estabas usando sin darte cuenta.
Lo mejor siempre es combinar varias técnicas
De hecho, incluso si solo aplicamos la técnica del property-based testing, lo más normal es que no tengamos suficiente
cobertura para todas las casuísticas que nuestro código debe contemplar. Por ejemplo, ¿qué ocurriría en el caso anterior,
si la implementación de nuestro método Add
devolviera el producto de los argumentos de entrada? Por supuesto, el test
seguiría pasando, pues la operación de multiplicación (producto) también cumple la propiedad conmutativa.
Por eso, lo mejor es combinar varias técnicas. En esta ocasión, podríamos recurrir a otras propiedades (cuyo conjunto nos permita garantizar el correcto funcionamiento de la función): conmutatividad, asociatividad, e identidad. E incluso podríamos combinar éstas con los tests basados en ejemplos, como lo veníamos haciendo hasta ahora.
Finalmente, nos gustaría cerrar el artículo con algo de deberes para vosotros: nuestros queridos lectores. Y es que si la explicación
de la técnica de property-based testing y la explicación del paquete "testing/quick"
os ha sabido a poco, nuestra recomendación (a modo de next steps) es que investiguéis el paquete
"leanovate/gopter"
.
Dicho paquete extiende la funcionalidad básica que ofrece "testing/quick"
con funcionalidades como el shrinking,
que se basa en la idea de que el paquete puede encontrar el caso mínimo de test reproducible para que el test falle.
Además de proporcionar varios helpers para los tests “con estado” así como varios generadores de código.
Y ahora sí, como ya sabéis, todos los comentarios, feedback o las dudas que hayan quedado por resolver, serán bienvenidas en la sección de comentarios del artículo o en nuestro Twitter @FriendsofGoTech.