Table-driven tests: el patrón estándar en Go

En Go, no escribimos una función de test por cada escenario posible. En su lugar, utilizamos table-driven tests. Este patrón consiste en definir una slice de estructuras anónimas (la “tabla”) que contiene los inputs, los resultados esperados y un nombre descriptivo para cada caso. La lógica de ejecución se escribe una sola vez y se itera sobre esa tabla usando un bucle.

Para que esto sea útil, es imperativo usar t.Run, que crea subtests. El uso de subtests permite que el runner de Go trate cada iteración como una unidad independiente. Esto es fundamental por tres razones: primero, si un caso falla, el resto de la tabla sigue ejecutándose; segundo, puedes ejecutar un caso específico desde la terminal usando go test -run TestNombre/NombreDelCaso; y tercero, permite usar t.Parallel() para ejecutar los escenarios de forma concurrente.

Si intentas implementar esta lógica con un bucle simple sin t.Run, un fallo en un caso difícil de depurar te obligará a analizar logs de texto para saber qué entrada causó el error. Además, si intentas usar t.Parallel() sin la estructura de subtests, el comportamiento del test será errático o los tests simplemente no se ejecutarán.

package main

import (
	"errors"
	"testing"
)

// ApplyDiscount calcula el precio final tras aplicar un cupón.
func ApplyDiscount(price float64, code string) (float64, error) {
	if price < 0 {
		return 0, errors.New("precio no puede ser negativo")
	}

	switch code {
	case "SAVE10":
		return price * 0.9, nil
	case "SAVE20":
		return price * 0.8, nil
	default:
		return price, nil
	}
}

func TestApplyDiscount(t *testing.T) {
	// Definimos la tabla de casos de prueba.
	// El campo 'name' debe ser el primero por convención.
	tests := []struct {
		name          string
		inputPrice    float64
		inputCode     string
		expectedPrice float64
		expectErr     bool
	}{
		{
			name:          "descuento del 10%",
			inputPrice:    100.0,
			inputCode:     "SAVE10",
			expectedPrice: 90.0,
			expectErr:     false,
		},
		{
			name:          "descuento del 20%",
			inputPrice:    100.0,
			inputCode:     "SAVE20",
			expectedPrice: 80.0,
			expectErr:     false,
		},
		{
			name:          "sin descuento (código inválido)",
			inputPrice:    100.0,
			inputCode:     "UNKNOWN",
			expectedPrice: 100.0,
			expectErr:     false,
		},
		{
			name:          "precio negativo lanza error",
			inputPrice:    -50.0,
			inputCode:     "SAVE10",
			expectedPrice: 0,
			expectErr:     true,
		},
	}

	for _, tc := range tests {
		// t.Run crea un subtest para cada entrada de la tabla.
		// Usamos tc.name para identificar el caso en los reportes.
		t.Run(tc.name, func(t *testing.T) {
			// t.Parallel permite que este subtest se ejecute en paralelo
			// con otros subtests que también llamen a t.Parallel().
			t.Parallel()

			got, err := ApplyDiscount(tc.inputPrice, tc.inputCode)

			// Verificamos si el error coincide con lo esperado.
			if (err != nil) != tc.expectErr {
				t.Fatalf("error inesperado: got %v, expectErr %v", err, tc.expectErr)
			}

			// Si el error era lo esperado, terminamos el subtest aquí.
			if tc.expectErr {
				return
			}

			// Verificamos que el resultado sea el correcto.
			if got != tc.expectedPrice {
				t.Errorf("ApplyDiscount() = %v; want %v", got, tc.expectedPrice)
			}
		})
	}
}

Desglose del código

  • La estructura de la tabla: La variable tests es una slice de una estructura anónima. Es una práctica estándar que el primer campo sea name (tipo string), ya que esto facilita la lectura y es lo que pasamos a t.Run.
  • Aislamiento con t.Run: Al llamar a t.Run(tc.name, ...), encapsulamos la ejecución. Si el caso “precio negativo lanza error” falla, t.Errorf marcará ese subtest como fallido, pero el bucle continuará con el siguiente caso de la tabla.
  • Paralelismo controlado: t.Parallel() dentro de t.Run le indica al scheduler de Go que este subtest puede ejecutarse de forma concurrente con otros. Esto es vital para optimizar el tiempo de ejecución en suites de tests grandes.
  • Control de flujo con t.Fatalf vs t.Errorf: En el subtest, usamos t.Fatalf cuando el error es tan crítico que no tiene sentido seguir probando ese caso (como cuando un error de entrada debería haber evitado el resto de la lógica). Usamos t.Errorf para fallos de valor, permitiendo que el reporte sea más detallado sin abortar el test prematuramente.

El error frecuente

Un error común ocurre cuando intentas usar t.Parallel() dentro de un bucle pero olvidas envolver la lógica en un t.Run.

// ERROR: Esto causará comportamientos erráticos
for _, tc := range tests {
    t.Parallel() // Esto marca el TEST PRINCIPAL como paralelo
    // Si haces esto, el bucle termina casi instantáneamente y
    // el test principal finaliza antes de que los subtests corran.
    got, _ := ApplyDiscount(tc.inputPrice, tc.inputCode)
    if got != tc.expectedPrice {
        t.Errorf("Error")
    }
}

Cuando llamas a t.Parallel() en el cuerpo principal de la función de test, el test actual se suspende para ejecutarse en paralelo con otros tests. Si el bucle no está contenido dentro de un t.Run, el bucle terminará, la función principal devolverá, y el runner de Go cerrará todos los tests antes de que las goroutines tengan oportunidad de ejecutarse.

153

Dejar un comentario

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

Scroll al inicio