Dominando Helper, Cleanup y Parallel en Testing de Go

Cuando escribes tests en Go, no basta con que el código pase; el código del test debe ser mantenible y los fallos deben ser fáciles de localizar. Para lograr esto, necesitas dominar tres herramientas: t.Helper(), t.Cleanup() y t.Parallel().

t.Helper() es una marca que le indica al runtime de Go que la función actual es una utilidad de asistencia. Esto sirve para que, cuando ocurra un fallo, el reporte de error apunte a la línea donde llamaste al helper y no a la línea interna de la función de utilidad. t.Cleanup() es un mecanismo para registrar funciones de limpieza que se ejecutan automáticamente al finalizar el test o el subtest, funcionando de forma similar a un defer, pero con un alcance (scope) ligado al ciclo de vida de *testing.T y no a la función actual. Por último, t.Parallel() le indica al ejecutor de tests que ese test en particular puede ejecutarse en una goroutine distinta de forma concurrente con otros tests marcados como paralelos, acelerando drásticamente la suite de pruebas.

Usar t.Helper() es esencial cuando creas funciones personalizadas para assertions. Si no lo usas, cada vez que un assert falle, el error te dirá que el problema está en tu archivo de utilidades de testing, obligándote a rastrear manualmente la pila de llamadas para saber qué test falló realmente. t.Cleanup() es tu mejor aliado para la gestión de recursos (como bases de datos temporales o archivos) porque garantiza que la limpieza ocurra incluso si el test falla, y a diferencia de defer, gestiona correctamente la jerarquía de subtests. t.Parallel() es indispensable en suites de tests masivas para aprovechar los núcleos de la CPU, pero requiere cuidado con el alcance de las variables. Si te equivocas con t.Parallel(), podrías terminar con condiciones de carrera en tus tests o, peor aún, con resultados falsos positivos debido a variables compartidas incorrectamente.

package main

import (
	"fmt"
	"testing"
)

// Store es un almacén simple en memoria para nuestro ejemplo.
type Store struct {
	data map[string]string
}

func (s *Store) Set(k, v string) {
	s.data[k] = v
}

func (s *Store) Get(k string) string {
	return s.data[k]
}

// setupStore configura el entorno de prueba.
func setupStore(t *testing.T) *Store {
	// Registramos la limpieza con t.Cleanup para que se ejecute 
	// al terminar el test que llamó a setupStore, sin importar si falla.
	t.Cleanup(func() {
		fmt.Println("Limpiando recursos de la Store...")
	})
	return &Store{data: make(map[string]string)}
}

// assertEqual es un helper personalizado para comparar strings.
func assertEqual(t *testing.T, got, want string) {
	// t.Helper() marca esta función como asistente.
	// Si fallamos, el error reportará la línea que llamó a assertEqual.
	t.Helper()
	if got != want {
		t.Errorf("resultado incorrecto: obtenido %q, esperado %q", got, want)
	}
}

func TestStore_ParallelTableDriven(t *testing.T) {
	// Probamos un almacén con múltiples casos de forma paralela.
	s := setupStore(t)

	tests := []struct {
		name  string
		key   string
		val   string
		want  string
	}{
		{"caso_simple", "user_1", "Alice", "Alice"},
		{"caso_vacío", "user_2", "", "", ""},
		{"caso_especial", "user_3", "Bob!", "Bob!", "Bob!"},
	}

	for _, tc := range tests {
		// Capturamos la variable de iteración para asegurar que
		// cada subtest use sus propios valores (crítico en versiones < 1.22).
		tc := tc 

		t.Run(tc.name, func(t *testing.T) {
			// Marcamos el subtest para que corra en paralelo con otros subtests.
			t.Parallel()

			s.Set(tc.key, tc.val)
			
			// Pequeño delay para simular carga y evidenciar la ejecución paralela.
			// En un test real, esto no es necesario.
			got := s.Get(tc.key)
			assertEqual(t, got, tc.want)
		})
	}
}

Desglose del ejemplo

En el código anterior, t.Helper() dentro de assertEqual es lo que salva tu productividad. Sin esa línea, si el tercer caso de la tabla fallara, el log de error te diría algo como asserts.go:45: resultado incorrecto..., perdiendo tiempo valioso. Con t.Helper(), el reporte te dirá directamente user_test.go:68: resultado incorrecto..., que es donde realmente está la lógica del test.

t.Cleanup en setupStore es preferible a un defer dentro de TestStore_ParallelTableDriven. Si usaras defer dentro del bucle de los subtests, la limpieza no ocurriría hasta que toda la función de prueba principal termine, lo que podría dejar recursos abiertos (como conexiones a bases de datos) mientras los subtests siguen ejecutándose en paralelo. t.Cleanup garantiza que el recurso se libere en cuanto el subtest específico que lo creó finaliza.

Cuando ejecutamos t.Parallel() dentro de t.Run, el test se pausa y se marca como “pendiente”. El runner de Go espera a que todos los tests de nivel superior terminen para empezar a ejecutar los que tienen la marca t.Parallel(). Esto permite que el scheduler de Go gestione la concurrencia de forma eficiente, ejecutando los subtests de la tabla en paralelo.

El error frecuente

Un error clásico ocurre al combinar t.Parallel() con bucles en versiones de Go anteriores a la 1.22. Aunque Go 1.22 [disponible desde Go 1.22] resolvió la captura de la variable del iterador en los bucles for estándar, el problema persiste si intentas usar una variable definida fuera del bucle que sea modificada en cada iteración.

Fíjate en este patrón peligroso:

// ERROR: Esto causará problemas si t.Parallel() está presente
for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // Si no hiciéramos tc := tc (en Go < 1.22), 
        // todos los subtests verían el último tc del bucle.
        s.Set(tc.key, tc.val) 
        assertEqual(t, s.Get(tc.key), tc.want)
    })
}

Aunque Go 1.22 es más seguro, la buena práctica sigue siendo ser explícito o entender que t.Parallel() cambia el flujo: el subtest se “sale” del hilo de ejecución actual y se queda esperando a que el bucle termine. Si no capturas la variable correctamente o si el objeto que estás modificando (como s en el ejemplo) no está protegido por un sync.Mutex, tendrás race conditions en tus propios tests. En el ejemplo inicial, s.Set y s.Get se llaman en paralelo; en un escenario real, Store necesitaría un sync.RWMutex para evitar que el test falle por una condición de carrera de memoria.

155

Dejar un comentario

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

Scroll al inicio