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:
testses un slice de struct anónimo. Cada elemento es un caso de prueba, con entradas (total,code) y salida esperada (want).- El campo
namete ayuda a identificar qué caso falla. - El bucle
forllama a la función y compara el resultado. El formato de mensajeF(entrada) = got, want Xes 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:
t.Fatalfdetiene toda la prueba. Un caso que falla hace que los casos posteriores no se ejecuten.- 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,
wantErrlleva el valor ceronil. En ese caso,errors.Is(nil, nil)devuelvetrue. - Si la función devuelve un error cuando no lo esperas,
errors.Is(err, nil)devuelvefalsey la prueba falla inmediatamente. errors.Isaún funciona cuando el error está envuelto confmt.Errorf("...: %w", err). La comparaciónerr == tc.wantErrno.
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 comoApplyDiscount, 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 booloexpectCall int, y el cuerpo del bucle está lleno deif, estás escribiendo lógica dentro de la prueba. En ese caso, separa en funciones de prueba independientes. - Usar
t.Fatalfuera det.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: 50000conGIAM50K).
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 def.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.Isusando un campowantErr 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.Diffcuando compares structs complejos. - Cuando la tabla empieza a contener lógica de control, es señal de que deberías separar tus pruebas.