Pruebas basadas en tablas en paralelo en Go
Acelere las pruebas de Go con ejecución en paralelo
Las pruebas basadas en tablas son el enfoque idiomático en Go para probar múltiples escenarios de manera eficiente.
Cuando se combinan con la ejecución paralela usando t.Parallel(), puedes reducir drásticamente el tiempo de ejecución del conjunto de pruebas, especialmente para operaciones acotadas por E/S.
Sin embargo, la prueba paralela introduce desafíos únicos alrededor de las condiciones de carrera y la aislamiento de pruebas que requieren atención cuidadosa.

Entendiendo la ejecución paralela de pruebas
El paquete de pruebas de Go proporciona soporte integrado para la ejecución paralela de pruebas a través del método t.Parallel(). Cuando una prueba llama a t.Parallel(), le señala al ejecutor de pruebas que esta prueba puede ejecutarse de forma segura en paralelo con otras pruebas paralelas. Esto es especialmente poderoso cuando se combina con pruebas basadas en tablas, donde tienes muchos casos de prueba independientes que pueden ejecutarse simultáneamente.
La paralelismo predeterminado está controlado por GOMAXPROCS, que generalmente es igual al número de núcleos de CPU en tu máquina. Puedes ajustar esto con la bandera -parallel: go test -parallel 4 limita las pruebas concurrentes a 4, independientemente del recuento de CPU. Esto es útil para controlar el uso de recursos o cuando las pruebas tienen requisitos específicos de concurrencia.
Para desarrolladores nuevos en pruebas de Go, entender los fundamentos es crucial. Nuestra guía sobre mejores prácticas para pruebas unitarias en Go cubre pruebas basadas en tablas, subpruebas y los fundamentos del paquete de pruebas que forman la base para la ejecución paralela.
Patrón básico de prueba basada en tabla en paralelo
Aquí está el patrón correcto para pruebas basadas en tabla en paralelo:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 5, 3, "-", 2, false},
{"multiplication", 4, 3, "*", 12, false},
{"division", 10, 2, "/", 5, false},
{"división por cero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
tt := tt // Capturar variable de bucle
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Habilitar ejecución paralela
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
La línea crítica es tt := tt antes de t.Run(). Esto captura el valor actual de la variable de bucle, asegurando que cada subprueba en paralelo opere en su propia copia de los datos del caso de prueba.
El problema de captura de variables de bucle
Este es uno de los errores más comunes al usar t.Parallel() con pruebas basadas en tablas. En Go, la variable de bucle tt se comparte entre todas las iteraciones. Cuando las subpruebas se ejecutan en paralelo, pueden todas hacer referencia a la misma variable tt, que se sobrescribe a medida que continúa el bucle. Esto lleva a condiciones de carrera y fallos de prueba impredecibles.
Incorrecto (condición de carrera):
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Todas las subpruebas pueden ver el mismo valor de tt!
result := Calculate(tt.a, tt.b, tt.op)
})
}
Correcto (variable capturada):
for _, tt := range tests {
tt := tt // Capturar la variable de bucle
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Cada subprueba tiene su propia copia de tt
result := Calculate(tt.a, tt.b, tt.op)
})
}
La asignación tt := tt crea una nueva variable con ámbito en la iteración del bucle, asegurando que cada goroutine tenga su propia copia de los datos del caso de prueba.
Asegurar la independencia de las pruebas
Para que las pruebas en paralelo funcionen correctamente, cada prueba debe ser completamente independiente. No deben:
- Compartir estado global o variables
- Modificar recursos compartidos sin sincronización
- Depender del orden de ejecución
- Acceder a los mismos archivos, bases de datos o recursos de red sin coordinación
Ejemplo de pruebas en paralelo independientes:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"correo electrónico válido", "user@example.com", false},
{"formato inválido", "not-an-email", true},
{"dominio faltante", "user@", true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
Cada caso de prueba opera en sus propios datos de entrada sin estado compartido, lo que lo hace seguro para su ejecución en paralelo.
Detectando condiciones de carrera
Go proporciona un poderoso detector de condiciones de carrera para capturar errores de carrera en pruebas en paralelo. Siempre ejecuta tus pruebas en paralelo con la bandera -race durante el desarrollo:
go test -race ./...
El detector de condiciones de carrera reportará cualquier acceso concurrente a la memoria compartida sin sincronización adecuada. Esto es esencial para capturar errores sutiles que pueden aparecer solo bajo condiciones de tiempo específicas.
Ejemplo de condición de carrera:
var counter int // Estado compartido - PELIGROSO!
func TestIncrement(t *testing.T) {
tests := []struct {
name string
want int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
counter++ // CONDICIÓN DE CARRERA!
if counter != tt.want {
t.Errorf("counter = %d, want %d", counter, tt.want)
}
})
}
}
Al ejecutar esto con -race se detectará la modificación concurrente de counter. La solución es hacer que cada prueba sea independiente usando variables locales en lugar de estado compartido.
Beneficios de rendimiento
La ejecución en paralelo puede reducir significativamente el tiempo de ejecución del conjunto de pruebas. La aceleración depende de:
- Número de núcleos de CPU: Más núcleos permiten que más pruebas se ejecuten simultáneamente
- Características de las pruebas: Las pruebas acotadas por E/S benefician más que las acotadas por CPU
- Número de pruebas: Los conjuntos de pruebas más grandes ven mayores ahorros de tiempo absolutos
Medir el rendimiento:
# Ejecución secuencial
go test -parallel 1 ./...
# Ejecución paralela (predeterminada)
go test ./...
# Paralelismo personalizado
go test -parallel 8 ./...
Para conjuntos de pruebas con muchas operaciones de E/S (consultas a la base de datos, solicitudes HTTP, operaciones de archivo), a menudo puedes lograr un aceleramiento de 2 a 4 veces en sistemas modernos con múltiples núcleos. Las pruebas acotadas por CPU pueden ver menos beneficios debido a la competencia por recursos de CPU.
Controlando el paralelismo
Tienes varias opciones para controlar la ejecución paralela de pruebas:
1. Limitar el máximo de pruebas paralelas:
go test -parallel 4 ./...
2. Establecer GOMAXPROCS:
GOMAXPROCS=2 go test ./...
3. Ejecución paralela selectiva:
Solo marca pruebas específicas con t.Parallel(). Las pruebas sin esta llamada se ejecutan secuencialmente, lo cual es útil cuando algunas pruebas deben ejecutarse en orden o compartir recursos.
4. Ejecución paralela condicional:
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("saltando prueba cara en modo corto")
}
t.Parallel()
// Lógica de prueba cara
}
Patrones comunes y mejores prácticas
Patrón 1: Configuración antes de la ejecución paralela
Si necesitas una configuración compartida entre todos los casos de prueba, hazla antes del bucle:
func TestWithSetup(t *testing.T) {
// Código de configuración se ejecuta una vez, antes de la ejecución paralela
db := setupTestDatabase(t)
defer db.Close()
tests := []struct {
name string
id int
}{
{"usuario1", 1},
{"usuario2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Cada prueba usa db independientemente
user := db.GetUser(tt.id)
// Lógica de prueba...
})
}
}
Patrón 2: Configuración por prueba
Para pruebas que necesitan configuración aislada, hazla dentro de cada subprueba:
func TestWithPerTestSetup(t *testing.T) {
tests := []struct {
name string
data string
}{
{"test1", "dato1"},
{"test2", "dato2"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Cada prueba obtiene su propia configuración
tempFile := createTempFile(t, tt.data)
defer os.Remove(tempFile)
// Lógica de prueba...
})
}
}
Patrón 3: Secuencial y paralelo mezclados
Puedes mezclar pruebas secuenciales y paralelas en el mismo archivo:
func TestSequential(t *testing.T) {
// Sin t.Parallel() - se ejecuta secuencialmente
// Bueno para pruebas que deben ejecutarse en orden
}
func TestParallel(t *testing.T) {
tests := []struct{ name string }{{"test1"}, {"test2"}}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Estas se ejecutan en paralelo
})
}
}
Cuándo NO usar la ejecución paralela
La ejecución paralela no siempre es adecuada. Evítala cuando:
- Las pruebas comparten estado: Variables globales, singleton o recursos compartidos
- Las pruebas modifican archivos compartidos: Archivos temporales, bases de datos de prueba o archivos de configuración
- Las pruebas dependen del orden de ejecución: Algunas pruebas deben ejecutarse antes que otras
- Las pruebas ya son rápidas: El costo de la paralelización puede superar los beneficios
- Restricciones de recursos: Las pruebas consumen demasiada memoria o CPU al ejecutarse en paralelo
Para pruebas relacionadas con bases de datos, considera usar rollbacks de transacciones o bases de datos separadas por prueba. Nuestra guía sobre patrones de base de datos multiinquilino en Go cubre estrategias de aislamiento que funcionan bien con pruebas paralelas.
Avanzado: Pruebas de código concurrente
Cuando pruebas código concurrente en sí mismo (no solo ejecutar pruebas en paralelo), necesitas técnicas adicionales:
func TestConcurrentOperation(t *testing.T) {
tests := []struct {
name string
goroutines int
}{
{"2 goroutines", 2},
{"10 goroutines", 10},
{"100 goroutines", 100},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var wg sync.WaitGroup
results := make(chan int, tt.goroutines)
for i := 0; i < tt.goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- performOperation()
}()
}
wg.Wait()
close(results)
// Verificar resultados
count := 0
for range results {
count++
}
if count != tt.goroutines {
t.Errorf("se esperaban %d resultados, se obtuvieron %d", tt.goroutines, count)
}
})
}
}
Siempre ejecuta tales pruebas con -race para detectar condiciones de carrera en el código bajo prueba.
Integración con CI/CD
Las pruebas en paralelo se integran de forma fluida con pipelines de CI/CD. La mayoría de los sistemas de CI proporcionan múltiples núcleos de CPU, lo que hace que la ejecución paralela sea altamente beneficiosa:
# Ejemplo de GitHub Actions
- name: Ejecutar pruebas
run: |
go test -race -coverprofile=coverage.out -parallel 4 ./...
go tool cover -html=coverage.out -o coverage.html
La bandera -race es especialmente importante en CI para capturar errores de concurrencia que pueden no aparecer en el desarrollo local.
Depuración de fallos en pruebas paralelas
Cuando las pruebas en paralelo fallan de forma intermitente, la depuración puede ser desafiante:
- Ejecutar con
-race: Identificar condiciones de carrera - Reducir paralelismo:
go test -parallel 1para ver si las fallas desaparecen - Ejecutar pruebas específicas:
go test -run TestNamepara aislar el problema - Añadir registro: Usar
t.Log()para rastrear el orden de ejecución - Revisar estado compartido: Buscar variables globales, singleton o recursos compartidos
Si las pruebas pasan secuencialmente pero fallan en paralelo, probablemente tengas un problema de condición de carrera o de estado compartido.
Ejemplo real: Pruebas de manejadores HTTP
Aquí hay un ejemplo práctico de pruebas de manejadores HTTP en paralelo:
func TestHTTPHandlers(t *testing.T) {
router := setupRouter()
tests := []struct {
name string
method string
path string
statusCode int
}{
{"GET usuarios", "GET", "/usuarios", 200},
{"GET usuario por ID", "GET", "/usuarios/1", 200},
{"POST usuario", "POST", "/usuarios", 201},
{"DELETE usuario", "DELETE", "/usuarios/1", 204},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.statusCode {
t.Errorf("se esperaba el estado %d, se obtuvo %d", tt.statusCode, w.Code)
}
})
}
}
Cada prueba usa httptest.NewRecorder(), que crea un registrador de respuesta aislado, lo que hace que estas pruebas sean seguras para su ejecución en paralelo.
Conclusión
La ejecución paralela de pruebas basadas en tablas es una técnica poderosa para reducir el tiempo de ejecución del conjunto de pruebas en Go. La clave para el éxito es entender el requisito de captura de variables de bucle, asegurar la independencia de las pruebas y usar el detector de condiciones de carrera para detectar problemas de concurrencia temprano.
Recuerda:
- Siempre capturar variables de bucle:
tt := ttantes det.Parallel() - Asegurar que las pruebas sean independientes sin estado compartido
- Ejecutar pruebas con
-racedurante el desarrollo - Controlar el paralelismo con la bandera
-parallelcuando sea necesario - Evitar la ejecución paralela para pruebas que comparten recursos
Siguiendo estas prácticas, puedes aprovechar la ejecución paralela para acelerar tus conjuntos de pruebas mientras mantienes su fiabilidad. Para más patrones de pruebas en Go, consulta nuestra guía completa sobre pruebas unitarias en Go, que cubre pruebas basadas en tablas, mocks y otras técnicas esenciales de prueba.
Cuando construyas aplicaciones Go más grandes, estas prácticas de prueba se aplican en diferentes dominios. Por ejemplo, cuando construyas aplicaciones CLI con Cobra y Viper, usarás patrones similares de pruebas paralelas para probar manejadores de comandos y análisis de configuración.
Enlaces útiles
- Hoja de trucos de Go
- Pruebas unitarias en Go: Estructura y mejores prácticas
- Construyendo aplicaciones CLI en Go con Cobra y Viper
- Patrones de base de datos multiinquilino con ejemplos en Go
- ORMs de Go para PostgreSQL: GORM vs Ent vs Bun vs sqlc