Table-driven test en Go

1. Visión general

Cuando una función tiene muchas ramas de procesamiento, querrás probar múltiples conjuntos de entrada/salida diferentes. Si escribes cada caso en una función de prueba separada, tu código de test tendrá mucha repetición y será difícil de mantener.

Table-driven test es la forma más común de escribir pruebas en la comunidad Go. Describes los casos de prueba como una “tabla” de datos, y luego usas un único bucle para ejecutarlos todos.

Después de leer este artículo, sabrás cómo escribir table-driven tests con subtests, verificar errores correctamente, ejecutar tests en paralelo y comparar structs complejos. También sabrás cuándo no deberías usar esta técnica.

2. La función a probar

Usaremos una función que calcula el precio después de aplicar un código de descuento a un pedido. Esta función tiene suficientes ramas para demostrar bien:

// pricing/discount.go
package pricing

import (
	"errors"
	"strings"
)

var (
	ErrNegativeTotal = errors.New("el monto total no puede ser negativo")
	ErrInvalidCode   = errors.New("código de descuento no válido")
)

// ApplyDiscount devuelve el monto (en unidades de moneda) después de aplicar el código de descuento.
func ApplyDiscount(total int, code string) (int, error) {
	if total < 0 {
		return 0, ErrNegativeTotal
	}

	switch strings.ToUpper(strings.TrimSpace(code)) {
	case "":
		return total, nil
	case "SALE10":
		return total * 90 / 100, nil
	case "GIAM50K":
		if total < 50000 {
			return 0, nil
		}
		return total - 50000, nil
	default:
		return 0, ErrInvalidCode
	}
}

La función tiene cinco comportamientos a verificar. Estos son: monto total negativo, sin código, código SALE10, código GIAM50K (dos ramas) y código inexistente. Además, el código se normaliza con TrimSpace y ToUpper.

3. Estructura básica de table-driven test

3.1. Primera versión

Un table-driven test consta de tres partes: declarar la tabla, iterar sobre ella y comparar resultados.

// pricing/discount_test.go
package pricing

import "testing"

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sin codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 minusculas y espacios", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k sin dinero negativo", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		got, err := ApplyDiscount(tc.total, tc.code)
		if err != nil {
			t.Fatalf("%s: error inesperado: %v", tc.name, err)
		}
		if got != tc.want {
			t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
				tc.name, tc.total, tc.code, got, tc.want)
		}
	}
}

Explicación:

  • tests es un slice de struct anónimo. Cada elemento es un caso de prueba, con entradas (total, code) y salida esperada (want).
  • El campo name te ayuda a identificar qué caso falla.
  • El bucle for llama a la función y compara el resultado. El formato de mensaje F(entrada) = got, want X es una convención familiar en Go.

Para agregar un nuevo caso, simplemente añades una línea a la tabla. Esta es la mayor ventaja de esta técnica.

3.2. Problemas de la primera versión

El código anterior funciona, pero tiene dos desventajas:

  1. t.Fatalf detiene toda la prueba. Un caso que falla hace que los casos posteriores no se ejecuten.
  2. No puedes ejecutar un solo caso mientras depuras.

Los subtests resuelven ambos problemas.

4. Usar subtests con t.Run

t.Run crea un subtest para cada caso. Cada subtest tiene su propio *testing.T, así que t.Fatal solo detiene el caso actual.

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sin codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 minusculas y espacios", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k sin dinero negativo", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("error inesperado: %v", err)
			}
			if got != tc.want {
				t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
					tc.total, tc.code, got, tc.want)
			}
		})
	}
}

Ya no necesitas imprimir tc.name en los mensajes de error, porque Go vincula el nombre del subtest a la salida. Ejecuta con -v:

go test ./pricing -v
=== RUN   TestApplyDiscount
=== RUN   TestApplyDiscount/sin_codigo
=== RUN   TestApplyDiscount/sale10
=== RUN   TestApplyDiscount/sale10_minusculas_y_espacios
=== RUN   TestApplyDiscount/giam50k_normal
=== RUN   TestApplyDiscount/giam50k_sin_dinero_negativo
--- PASS: TestApplyDiscount (0.00s)
    --- PASS: TestApplyDiscount/sin_codigo (0.00s)
    --- PASS: TestApplyDiscount/sale10 (0.00s)
    --- PASS: TestApplyDiscount/sale10_minusculas_y_espacios (0.00s)
    --- PASS: TestApplyDiscount/giam50k_normal (0.00s)
    --- PASS: TestApplyDiscount/giam50k_sin_dinero_negativo (0.00s)
PASS

Verás que los espacios en blanco del nombre se reemplazaron con guiones bajos. Para ejecutar solo un caso, pasa una expresión regular a -run:

go test ./pricing -run 'TestApplyDiscount/giam50k' -v

Este comando ejecuta solo los dos casos cuyo nombre comienza con giam50k. Cuando un caso falla, la salida se ve así:

--- FAIL: TestApplyDiscount (0.00s)
    --- FAIL: TestApplyDiscount/sale10 (0.00s)
        discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000

Sabes inmediatamente qué caso se rompió, en qué línea y con qué entrada.

Nota: Pon nombres de caso cortos, sin duplicados y fáciles de escribir en la terminal. Si dos casos tienen el mismo nombre, Go añadirá automáticamente un sufijo #01, haciendo más difícil ejecutar un caso específico.

5. Verificar errores en la tabla de prueba

Los casos que devuelven errores también deben estar en la misma tabla. Añades un campo wantErr de tipo error, luego comparas usando errors.Is:

package pricing

import (
	"errors"
	"testing"
)

func TestApplyDiscount_Errors(t *testing.T) {
	tests := []struct {
		name    string
		total   int
		code    string
		want    int
		wantErr error
	}{
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "monto total negativo", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
		{name: "codigo inexistente", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)

			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
			}
			if tc.wantErr != nil {
				return // ya verificamos el error, no es necesario examinar el valor devuelto
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

Explicación:

  • Para un caso exitoso, wantErr lleva el valor cero nil. En ese caso, errors.Is(nil, nil) devuelve true.
  • Si la función devuelve un error cuando no lo esperas, errors.Is(err, nil) devuelve false y la prueba falla inmediatamente.
  • errors.Is aún funciona cuando el error está envuelto con fmt.Errorf("...: %w", err). La comparación err == tc.wantErr no.

Muchos códigos usan wantErr bool para simplificar. Eso solo te dice si hay un error, no qué error es. Deberías preferir comparar con errores centinela si tu paquete los define.

6. Ejecutar en paralelo con t.Parallel

Cuando los casos de prueba son lentos (llamadas de E/S, procesamiento pesado), puedes hacer que los subtests se ejecuten en paralelo:

func TestApplyDiscount_Parallel(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sin codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("error inesperado: %v", err)
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

t.Parallel() le dice al ejecutor de pruebas que este subtest puede ejecutarse junto con otros subtests en paralelo. La función padre pausará temporalmente los subtests, terminará el bucle y luego soltará todos los subtests para ejecutarlos simultáneamente.

Desde Go 1.22, cada iteración del bucle for crea una nueva variable tc, así que el closure siempre captura el valor correcto. Con Go 1.21 o anterior, debes copiar la variable:

for _, tc := range tests {
	tc := tc // necesario para Go < 1.22
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

Si omites la línea tc := tc en versiones anteriores, todos los subtests usarán el último caso. La prueba aún “pasará” pero en realidad solo verificarás un escenario. Este es un error muy difícil de detectar. Verifica la línea go en tu go.mod.

Nota: Solo usa t.Parallel() cuando los casos no compartan estado que pueda cambiar, como variables globales, archivos temporales compartidos o la misma tabla de base de datos. Con funciones puras como ApplyDiscount, el beneficio es casi nulo. No lo añadas por costumbre.

7. Comparar structs complejos con go-cmp

Cuando una función devuelve un struct anidado, el operador != no funciona para structs que contienen slices o maps. Además, reflect.DeepEqual solo devuelve true/false, sin decirte dónde difieren. La librería go-cmp resuelve esto. La añades a tu módulo:

go get github.com/google/go-cmp/cmp
// go.mod (extracto)
require github.com/google/go-cmp v0.6.0

Supongamos que tienes una función que crea una factura:

// pricing/invoice.go
package pricing

type Invoice struct {
	Customer string
	Items    []string
	Total    int
}

func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
	final, err := ApplyDiscount(total, code)
	if err != nil {
		return Invoice{}, err
	}
	return Invoice{Customer: customer, Items: items, Total: final}, nil
}

La prueba usa cmp.Diff:

package pricing

import (
	"testing"

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

func TestBuildInvoice(t *testing.T) {
	tests := []struct {
		name     string
		customer string
		items    []string
		total    int
		code     string
		want     Invoice
	}{
		{
			name:     "con codigo sale10",
			customer: "An",
			items:    []string{"camisa", "gorro"},
			total:    200000,
			code:     "SALE10",
			want:     Invoice{Customer: "An", Items: []string{"camisa", "gorro"}, Total: 180000},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
			if err != nil {
				t.Fatalf("error inesperado: %v", err)
			}
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("BuildInvoice() difiere (-want +got):\n%s", diff)
			}
		})
	}
}

cmp.Diff devuelve una cadena vacía cuando los dos valores son iguales. Si difieren, obtienes un diff legible:

BuildInvoice() difiere (-want +got):
  pricing.Invoice{
  	Customer: "An",
  	Items:    {"camisa", "gorro"},
- 	Total:    180000,
+ 	Total:    200000,
  }

El signo - es el valor esperado y el signo + es el valor real. La convención (-want +got) debe escribirse claramente en el mensaje para que el lector no tenga que adivinar.

8. Errores comunes y cuándo no usar esta técnica

Algunos errores comunes al escribir table-driven tests:

  • La tabla se vuelve enorme con demasiados campos flag. Cuando tu struct de prueba tiene campos como setupMock bool, skipValidation bool o expectCall int, y el cuerpo del bucle está lleno de if, estás escribiendo lógica dentro de la prueba. En ese caso, separa en funciones de prueba independientes.
  • Usar t.Fatal fuera de t.Run. Un caso que falla te hará perder los resultados de todos los casos posteriores.
  • Usar map en lugar de slice sin entender las consecuencias. map[string]struct{...} evita nombres duplicados y el orden de ejecución es aleatorio, lo que ayuda a exponer dependencias ocultas entre casos. Sin embargo, la salida no será en un orden fijo. Ambas formas son aceptables, siempre que tu equipo esté de acuerdo.
  • Mensajes de error sin entradas. Escribir solo t.Errorf("mal") obliga a quien depura a abrir el código de la prueba y leerlo. Siempre imprime entrada, got y want.
  • Olvidar casos límite. La tabla te ayuda a ver fácilmente qué casos faltan. Revisa valores 0, números negativos, cadenas vacías y valores justo en el límite (por ejemplo, total: 50000 con GIAM50K).

No deberías usar table-driven tests cuando:

  • Cada caso necesita un setup muy diferente, como mocks que devuelven datos distintos u órdenes de llamadas diferentes. Forzarlos en una tabla solo hace la prueba más difícil de leer.
  • Solo tienes uno o dos casos. Una función de prueba directa será más fácil de entender.
  • Necesitas exploración con entrada aleatoria. En ese caso, considera fuzz tests (func FuzzXxx(f *testing.F)), disponibles desde Go 1.18. Puedes usar tu tabla de prueba como seed corpus a través de f.Add.

9. Conclusión

Table-driven test te ayuda a agrupar muchos casos de prueba en una tabla de datos y ejecutarlos con un único bucle. Combinado con t.Run, cada caso se convierte en un subtest independiente. Puedes ejecutar casos individuales con -run, y un caso que falla no detiene los demás.

Puntos clave a recordar:

  • Cada caso debe tener name, entradas y salida esperada.
  • Verifica errores con errors.Is usando un campo wantErr error.
  • Solo usa t.Parallel() cuando realmente lo necesites, y ten cuidado con la semántica de variables de bucle antes de Go 1.22.
  • Usa cmp.Diff cuando compares structs complejos.
  • Cuando la tabla empieza a contener lógica de control, es señal de que deberías separar tus pruebas.
golangtestingtable-driven testprueba unitaria