En pleno 2024 es posible seguir encontrado ciertas “prácticas” en recursos oficiales que a mí personalmente me sorprenden bastante. Hoy os voy a hablar de una en concreto que me he encontrado leyendo el libro The Go Programming Language
Disclamer
No hace falta decir que todo esto es MI opinión, basada en mi experiencia, nada más.
Los autores del libro Alan A. A. Donovan y Brian W. Kernighan son unas personas brillantes y mucho más inteligente que yo. Por lo que seguro que para ellos existe un motivo para todo esto que voy a hablar a continuación.
Hace unos días estaba buscando algo de información “oficial” sobre como hacer testing en Go y así refrescar un poco lo que había mirado en su día. La primera fuente que se me vino a la mente fue el libro “oficial” del lenguaje, el cual me dejo un poco desconcertado, ya que proponía abiertamente el uso y modificación de variables globales para hacer testing.
Testing modificando variables globales
Como vamos a ver a continuación el ejemplo es muy tonto, implementar y testear un echo
. Lo que me gusta es que trata sobre una
casuística muy particular como puede ser testear fechas
o prints
por consola.
En el libro dice abiertamente, para validar que la función echo
ha hecho un print
por consola con un mensaje concreto debemos crear una variable
global y pisarla en los tests. Además añade:
From the test, we will call echo with a variety of arguments and flag settings and check that it prints
the correct output in each case, so we’ve added parameters to echo to reduce its dependence on global
variables.
Y se dejan fuera lo más importate, la variable global echo
que vamos a testear 🤣.
Vamos a ver el ejemplo:
package main
...
var out io.Writer = os.Stdout // OVERRIDE DURING TESTING
...
func echo(newline bool, sep string, args []string) error {
fmt.Fprint(out, strings.Join(args, sep)) // WRITE USING GLOBAL VARIABLE
if newline {
fmt.Fprintln(out)
}
return nil
}
package main
...
for _, test := range tests {
descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
out = new(bytes.Buffer) // OVERRIDE OUT VARIABLE
if err := echo(test.newline, test.sep, test.args); err != nil {
t.Errorf("%s failed: %v", descr, err)
continue
}
got := out.(*bytes.Buffer).String() // GET VALUE
if got != test.want {
t.Errorf("%s = %q, want %q", descr, got, test.want)
}
}
El código en cuestión está disponible aquí
Como decía el ejemplo es tontísimo. Lo que me preocupa más es el trasfondo de esta práctica.
Vamos a hacer una lista rápida de los pros y contras de esta forma de testear:
PROS
- ¿Es más rápido de implementar? (Lo pregunto porque sinceramente no lo tengo nada claro).
- Para un ejemplo simple y aislado funciona (aunque no escale).
CONTRAS
- El test está modificando una variable global.
- La clase y el test están muy acoplados a nivel interno:
- El test tiene que saber que se está usando una variable global llamada
out
, la cual tiene que pisar.
- El test tiene que saber que se está usando una variable global llamada
- El test es muy frágil:
- Si en la clase que se testea se cambia el nombre de la variable
out
, el test empezará a fallar. - Si otro test modifica la variable global
out
podría afectar a otros tests. - El orden en el que se define la variable
out
en el test no es trivial, una línea después y el test fallaría. - El test funciona porque en
Go
el test y la case testeada pertenecen temporalmente al mismo paquete y por ello la variable global a nivel de paquete está disponible.
- Si en la clase que se testea se cambia el nombre de la variable
Testing usando inyección de dependencias
Lo que yo propongo es algo mucho más limpio, extensible y sobre todo sin andar pisando variables globales.
Simplemente, inyectar la variable out
en la función echo
que queremos testear:
func main() {
flag.Parse()
var out io.Writer = os.Stdout // CREATE A LOCAL WRITER CLASS INSTEAD OF USING A GLOBAL VARIABLE
if err := echo(out, !*n, *s, flag.Args()); err != nil { // INJECT IT INTO THE ECHO FUNCTION
fmt.Fprintf(os.Stderr, "echo: %v\n", err)
os.Exit(1)
}
}
// RECIEVE OUT VARIABLE AS PARAMETER
func echo(out io.Writer, newline bool, sep string, args []string) error {
...
}
for _, test := range tests {
descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
var out = new(bytes.Buffer) // CREATE A WRITER LIKE CLASS JUST FOR THIS TEST
if err := echo(out, test.newline, test.sep, test.args); err != nil { // INJECT INTO ECHO FUNCTION
t.Errorf("%s failed: %v", descr, err)
continue
}
got := out.String() // READ OUTPUT
if got != test.want {
t.Errorf("%s = %q, want %q", descr, got, test.want)
}
}
El código en cuestión está disponible aquí
Como podéis ver el test y la clase están acoplados lo justo y necesario, el test no modifica nada y es mucho más fácil de leer y entender.
Vamos a repetir la lista rápida de los pros y contras de esta forma de testear:
PROS
- El test no modifica nada fuera del mismo.
- El test y la clase están muy poco acoplados.
- Cambios en la clase no afectan al test.
- Al inyectar un objeto que implemente la interfaz
Writer
a la funciónecho
podemos usar diferentes clases según el escenario que estemos cubriendo:- Para el código de producción usamos la clase
os.Stdout
, que no dejar de ser una clase de tipofile
y que implementa la interfazWriter
. - Para el test usamos la clase
bytes.Buffer
que implementa la interfazWriter
directamente.
- Para el código de producción usamos la clase
- Cada test tiene su instancia del
Writer
y, por tanto, son independientes entre sí.
CONTRAS
- Requiere de un poco más de esfuerzo (completamente asumible y que a largo plazo estara más que amortizado).
- La función
echo
tiene muchos parámetros.- Para esto podemos empaquetar los parámetros en un objeto y pasarlo como parámetro, reduciendo el
arity
de la función.
- Para esto podemos empaquetar los parámetros en un objeto y pasarlo como parámetro, reduciendo el
Conclusiones
Este post es más una forma de desahogo que una receta mágica.
Es igual de posible que lo hayas leído y te aporte algo como que no estés para nada de acuerdo conmigo. No pasa nada, no pretendo poseer la verdad universal ni mucho menos.
Lo que sí que creo es que es bueno formase ideas propias con base en nuestras experiencias y no aceptar las cosas porque si.
No tengo ninguna duda de que esta gente es mucho más lista y tiene muchísima más experiencia que yo, pero no por ello voy a aceptar todo lo que digan como si fuera la verdad absoluta. En este caso, y como decía siempre basándome en mi propia experiencia, no creo que recomendar este tipo de práctica a la hora de testear para alguien nuevo en un lenguaje como puede ser Go sea lo mejor.
!Un saludo!