Pruebas basadas en tablas en paralelo en Go

Acelere las pruebas de Go con ejecución en paralelo

Índice

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.

Pruebas basadas en tablas en paralelo en Go - condiciones de carrera en golang

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:

  1. Las pruebas comparten estado: Variables globales, singleton o recursos compartidos
  2. Las pruebas modifican archivos compartidos: Archivos temporales, bases de datos de prueba o archivos de configuración
  3. Las pruebas dependen del orden de ejecución: Algunas pruebas deben ejecutarse antes que otras
  4. Las pruebas ya son rápidas: El costo de la paralelización puede superar los beneficios
  5. 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:

  1. Ejecutar con -race: Identificar condiciones de carrera
  2. Reducir paralelismo: go test -parallel 1 para ver si las fallas desaparecen
  3. Ejecutar pruebas específicas: go test -run TestName para aislar el problema
  4. Añadir registro: Usar t.Log() para rastrear el orden de ejecución
  5. 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 := tt antes de t.Parallel()
  • Asegurar que las pruebas sean independientes sin estado compartido
  • Ejecutar pruebas con -race durante el desarrollo
  • Controlar el paralelismo con la bandera -parallel cuando 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

Recursos externos