Testing con Golden Files en Go

Cuando necesitas validar la salida de una función que genera estructuras masivas —un JSON de 500 líneas, un HTML complejo, un XML o el output de un CLI— escribir assertions manuales para cada campo es una pérdida de tiempo y una pesadilla de mantenimiento. El patrón Golden Files soluciona esto comparando el resultado actual contra un archivo de referencia guardado físicamente en el repositorio, generalmente dentro de un directorio testdata/.

El mecanismo es simple: en lugar de comparar got == want, comparas got == archivo_en_disco. La lógica de decisión se delega a una bandera de comando (usualmente -update) que cambia el comportamiento del test: si la bandera está activa, el test sobreescribe el archivo con el nuevo output; si no, simplemente compara el resultado contra el archivo existente. Este enfoque es extremadamente útil cuando el contrato de salida es complejo pero estable, permitiéndote centrarte en la lógica del código y no en la sintaxis de la comparación.

Para implementar esto, aprovechamos que go test permite pasar flags personalizados. Si el test detecta que la bandera de actualización está activa, actúa como un “grabador”; de lo contrario, actúa como un “validador”. Sin embargo, si implementas esto sin un proceso de revisión estricto, corres un riesgo crítico: si introduces un bug que cambia el formato de salida y ejecutas el test con la bandera de actualización, estarás “bendiciendo” el error al guardarlo como la nueva verdad absoluta en tu repositorio.

package example_test

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"testing"

	"github.com/google/go-cmp/cmp"
)

// Report es una estructura compleja que queremos validar con Golden Files.
type Report struct {
	ID        string            `json:"id"`
	Status    string            `json:"status"`
	Metadata  map[string]string `json:"metadata"`
	Timestamp string            `json:"timestamp"`
}

// GenerarReporte simula la lógica de negocio que produce un output complejo.
func GenerarReporte(id string) (*Report, error) {
	// En un caso real, esto vendría de una DB o un proceso complejo.
	return &Report{
		ID:     id,
		Status: "PROCESADO",
		Metadata: map[string]string{
			"region": "us-east-1",
			"env":    "production",
		},
		Timestamp: "2023-10-27T10:00:00Z",
	}, nil
}

// update es una bandera que controlará si regeneramos los archivos de referencia.
var update = flag.Bool("update", false, "actualizar archivos golden")

func TestGenerarReporte_Golden(t *testing.T) {
	id := "req-12345"
	report, err := GenerarReporte(id)
	if err != nil {
		t.Fatalf("falló la ejecución: %v", err)
	}

	// Preparamos el path hacia el directorio testdata/
	// Se usa testdata/ porque Go la reconoce como un directorio de datos de test.
	goldenPath := filepath.Join("testdata", "report_"+id+".json")

	// Convertimos el objeto actual a JSON para la comparación
	gotBytes, err := json.MarshalIndent(report, "", "  ")
	if err != nil {
		t.Fatalf("error mariscalando JSON: %v", err)
	}

	if *update {
		// MODO GRABADOR: Aseguramos que el directorio exista y escribimos el archivo.
		err := os.MkdirAll(filepath.Dir(goldenPath), 0755)
		if err != nil {
			t.Fatalf("no se pudo crear testdata: %v", err)
		}

		if err := os.WriteFile(goldenPath, gotBytes, 0644); err != nil {
			t.Fatalf("no se pudo escribir el archivo golden: %v", err)
		}
		t.Logf("Archivo golden actualizado: %s", goldenPath)
		return
	}

	// MODO VALIDADOR: Leemos el archivo existente para comparar.
	wantBytes, err := os.ReadFile(goldenPath)
	if err != nil {
		if os.IsNotExist(err) {
			t.Fatalf("archivo golden no encontrado. Ejecuta `go test -update` para generarlo: %s", goldenPath)
		}
		t.Fatalf("error leyendo archivo golden: %v", err)
	}

	// Usamos google/go-cmp para obtener un diff legible si la comparación falla.
	// MarshalIndent asegura que la comparación sea sobre texto formateado.
	if diff := cmp.Diff(string(wantBytes), string(gotBytes)); diff != "" {
		t.Errorf("el output no coincide con el archivo golden (-want +got):\n%s", diff)
	}
}

Desglose del ejemplo

El corazón de este patrón es la gestión del flag update. Definimos var update = flag.Bool(...) a nivel de paquete para que go test pueda inyectar su valor. Cuando ejecutas go test -v -update, la lógica entra en el bloque if *update.

  1. Escritura (Modo Grabador): Usamos os.MkdirAll para asegurar que el directorio testdata/ exista. Si el archivo ya existe, os.WriteFile lo sobreescribe. Esto es vital para cuando tu lógica de negocio cambia intencionalmente y necesitas actualizar tus “verdades” de test.
  2. Lectura (Modo Validador): Si la bandera no está activa, el test intenta leer el archivo. Si el archivo no existe (os.IsNotExist), el test falla con un mensaje instructivo en lugar de un error genérico, indicando exactamente qué comando ejecutar.
  3. Comparación con go-cmp: En lugar de un reflect.DeepEqual o una comparación de strings simple, utilizamos cmp.Diff. Si el JSON generado cambia (por ejemplo, un campo status que pasó de PROCESADO a COMPLETADO), cmp.Diff te devolverá un diff estilo Git:
        - "status": "PROCESADO"
        + "status": "COMPLETADO"
        

    Esto es fundamental para entender qué cambió sin tener que inspeccionar visualmente dos archivos gigantes.

El error frecuente

El mayor peligro de los Golden Files no es técnico, es procedimental. Ocurre cuando un desarrollador rompe accidentalmente un campo en la estructura de salida y, en lugar de investigar el error, simplemente ejecuta:

go test ./... -update

Al hacer esto, el test “pasa” porque el archivo de referencia ahora contiene el error. Si este cambio se incluye en un Pull Request sin revisar minuciosamente el contenido del archivo .json modificado, el bug se vuelve parte del código “correcto”.

Para mitigar esto, en entornos de CI (Continuous Integration), los archivos .golden deben estar versionados en Git. La revisión de código (Code Review) debe tratar los cambios en testdata/*.json con la misma seriedad que el código fuente: si un JSON cambia, el revisor debe verificar que ese cambio es la consecuencia esperada de la lógica modificada.

159

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio