Ya sabéis que los artículos de testing son un clásico en nuestro blog. Y, si la semana pasada os presentamos una de las novedades de la recientemente publicada Go 1.14: el método Cleanup, hoy os queremos presentar un concepto que ya se viene usando en la comunidad Go desde hace mucho tiempo, pero del que poco se ha hablado, pues lo cierto es que cubre una necesidad considerablemente específica.
Contextualizando el problema
Como bien indica el título del artículo, hoy vamos a ver cómo desarrollar tests con los llamados golden files, pero primero entendamos para qué los necesitamos. Imaginad que estamos escribiendo los tests unitarios de uno de nuestros desarrollos y necesitamos comprobar que el retorno de una función se corresponde con un contenido muy grande. Como ya adelantemos, se trata de un caso específico, pero se me vienen a la mente una gran cantidad de ejemplos válidos (un parser de cualquier formato -JSON, XML, HTML, etc-, una librería de templating, una librería de manipulación de imágenes, etc).
En esa tesitura, tendríamos varias formas de enfocar el problema. Por ejemplo, si el retorno que quisiéramos comprobar
fuera un JSON, podríamos hacer uso del paquete encoding/json
para definir la salida esperada en forma de struct, convertirlo
a un slice de bytes y comparar los datos.
Sin embargo, ese enfoque no sería del todo elegante, pues si los datos a comprobar son muy grandes, tendríamos que definir structs
muy grandes en el código de nuestros tests que harían que estos fueran complicados de leer, por no hablar de temas de rendimiento.
Además, ese enfoque solo nos solventaría la papeleta para el caso de JSON, o quizás para algún otro caso específico como podría
ser comprobar datos en formato XML (encoding/xml
), pero no por ejemplo para el caso de una salida que devuelve los bytes de una
imagen modificados tras aplicar un determinado filtro.
Los ficheros de toda la vida
Vale, a estas alturas, probablemente muchos de vosotros estaréis pensado: “Oye, y ¿porqué no definimos los datos esperados en un fichero estático y comprobamos que el retorno de la función que estamos testeando es igual al contenido de dicho fichero?”
Pues nada más lejos de la realidad. Los golden files no dejan de ser los ficheros con datos de test de toda la vida, pero definidos siguiendo la convención Go, tal y como este patrón es aplicado en los tests de la librería estándar.
Las convenciones de los golden files
Entonces, la siguiente pregunta es: ¿cuáles son esas convenciones que se siguen en Go?
Lo primero de todo, los ficheros con los datos para los tests irán en un directorio llamado testdata
y tendrán un
nombre que nos permita relacionarlos fácilmente con los tests en sí. Adicionalmente podemos añadir un sufijo si vamos
a hacer table driven tests y queremos tener varios ficheros para un mismo test.
Lo segundo, los ficheros con los datos de tests tendrán la extensión .golden
.
Y lo tercero, proporcionaremos un mecanismo (-update
) para simplificar el mantenimiento de dichos ficheros,
es decir, para actualizar su valor cada vez que cambiemos la implementación.
Caso práctico
Vale, ahora que ya tenemos la teoría clara, veamos un ejemplo práctico. Si bien es cierto que lo ideal para ver el potencial de este enfoque sería trabajar con datos muy grandes y complejos (datos en binario, por ejemplo), el ejemplo que vamos a ver será muy sencillo (pocos datos y en JSON), con el fin de simplificar el artículo y que quede clara la idea (los datos en sí son lo de menos).
Como veréis al final del artículo, los pasos que vamos a seguir aquí los podréis seguir al pie de la letra aunque vuestra función trabaje con grandes cantidades de datos formateados de cualquier otro modo.
Imaginemos que tenemos la siguiente estructura de datos:
type Gopher struct {
Name string `json:"name"`
Color string `json:"color"`
}
Y que queremos testear la siguiente función “genérica” que serializa a JSON (extraída del paquete encoding/json
):
func Marshal(v interface{}) ([]byte, error) {
e := newEncodeState()
err := e.marshal(v, encOpts{escapeHTML: true})
if err != nil {
return nil, err
}
buf := append([]byte(nil), e.Bytes()...)
encodeStatePool.Put(e)
return buf, nil
}
Lo primero, como dijimos anteriormente, será crear ese directorio testdata
con nuestro golden file, por ejemplo
testdata/TestMarshal.golden
:
{"name":"Will","color":"blue"}
Y lo siguiente será escribir el test en cuestión:
package json
import (
"bytes"
"io/ioutil"
"os"
"testing"
)
func TestMarshal(t *testing.T) {
expected := goldenData(t, "TestMarshal")
gopher := Gopher{Name: "Will", Color: "blue"}
got, err := Marshal(&gopher)
if err != nil {
t.Errorf("marshal failed: %s", err.Error())
}
if !bytes.Equal(expected, got) {
t.Errorf("marshaled json does not match .golden file")
}
}
func goldenData(t *testing.T, identifier string) []byte {
t.Helper()
goldenPath := "testdata/" + identifier + ".golden"
f, err := os.OpenFile(goldenPath, os.O_RDWR, 0644)
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
t.Fatalf("Error opening file %s: %s", goldenPath, err)
}
return data
}
Como podéis ver, lo que hemos hecho es definir un helper que leerá el fichero en cuestión y nos devolverá los datos esperados. Si los datos no coinciden, el test fallará.
Además, también podríamos definir varios ficheros con datos y seguir la habitual práctica del table driven testing siguiendo este mismo patrón, leyendo los datos de cada fichero en cada subtest. Hasta aquí todo bien.
Sin embargo, ¿qué ocurre si el día de mañana queremos cambiar el comportamiento de nuestra función? Una opción sería
modificar todos los golden files a mano con los datos esperados. Sin embargo, en según que casos eso puede resultar
muy engorroso. Por esa razón, el último paso que definimos anteriormente es el de dotar a nuestros tests de un mecanismo
de actualización de dichos datos. Para ello usaremos el paquete flag
, veamos el ejemplo de antes adaptado:
package json
import (
"bytes"
"io/ioutil"
"os"
"testing"
)
var (
update = flag.Bool("update", false, "update the golden files of this test")
)
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}
func TestMarshal(t *testing.T) {
gopher := Gopher{Name: "Will", Color: "blue"}
got, err := Marshal(&gopher)
if err != nil {
t.Errorf("marshal failed: %s", err.Error())
}
expected := goldenData(t, "TestMarshal", *update, got)
if !bytes.Equal(expected, got) {
t.Errorf("marshaled json does not match .golden file")
}
}
func goldenData(t *testing.T, identifier string, update bool, new []byte) []byte {
t.Helper()
goldenPath := "testdata/" + identifier + ".golden"
f, err := os.OpenFile(goldenPath, os.O_RDWR, 0644)
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
t.Fatalf("Error opening file %s: %s", goldenPath, err)
}
return data
}
Como podéis ver, básicamente lo que hemos hecho es usar el paquete flag
y definir un TestMain
tal y como nos recomiendan
en la documentación oficial para
proporcionar ese mecanismo de actualización de los datos.
De forma que ahora podríamos ejecutar nuestros tests haciendo uso de dicho flag:
go test -v ./... -update
Usando una librería externa
Vale, hasta aquí ha quedado clara la idea. Hemos visto un ejemplo completamente funcional y podemos empezar a usar esta práctica para desarrollar nuestros tests.
Sin embargo, los que vayáis más avanzados, estaréis pensado que aquí aún faltan algunos aspectos por pulir. Dos de esos aspectos serían:
- En código de producción (en un caso real), dónde y cómo definir esos helpers para recuperar y actualizar los datos de los golden files.
- Y, cómo usar golden files cuándo tenemos datos dinámicos (por ejemplo, un identificador o una fecha).
Bien, pues ese par de aspectos también los vamos a resolver aquí, y lo vamos a hacer mediante el uso de la librería Goldie. Una librería específicamente desarrollada como librería de soporte para usar golden files en nuestros tests.
Con el uso de dicha librería podríamos dar el primer punto por cerrado, pues como podemos ver en su documentación, la librería ya nos proporciona una API bastante amigable para no tener que preocuparnos de nada.
Respecto al segundo caso, podemos hacer uso de las plantillas que nos proporciona la misma librería para trabajar con
datos dinámicos. Así, para finalizar, veamos el ejemplo anterior adaptado añadiendo ese par de campos conflictivos a
nuestra estructura de datos (ID
y CreatedAt
).
{id:{{ .ID }},"name":"Will","color":"blue", "created_at":{{ .CreatedAt }}}
Por un lado tenemos que actualizar nuestros golden files con el formato de templating nativo de Go:
{{ .NombreVariable}}
y, por otro lado, tenemos que actualizar el código de nuestros tests:
func TestMarshal(t *testing.T) {
// Call to an external factory to simulate dynamic fields
gopher := gopher.New("Will", "blue")
got, err := Marshal(&gopher)
if err != nil {
t.Errorf("marshal failed: %s", err.Error())
}
data := struct {
ID string
CreatedAt string
}{
ID: gopher.ID,
CreatedAt: fmt.Sprintf("%s", gopher.CreatedAt),
}
g := goldie.New(t)
g.AssertWithTemplate(t, "TestMarshal", data, got)
}
¡E voilà! Ya tendríamos nuestra función testeada haciendo uso de los golden files con la librería Goldie.
¿Qué os ha parecido esta estrategia? ¿Y la librería Goldie?
Ahora es vuestro turno, a ver quién se anima a abrirnos una PR a nuestro proyecto Killgrave modificando el enfoque que usamos actualmente para los tests y pasar a hacer uso de lo aprendido en este artículo.
Como siempre, estaremos muy agradecidos de que nos dejéis vuestras experiencias personales, dudas o sugerencias en los comentarios o en nuestra cuenta de Twitter.