Pruebas unitarias en Go: Estructura y mejores prácticas

Pruebas en Go desde lo básico hasta patrones avanzados

Índice

El paquete de prueba integrado de Go proporciona un marco poderoso y minimalista para escribir pruebas unitarias sin dependencias externas. Aquí están los fundamentos de las pruebas, la estructura del proyecto y los patrones avanzados para construir aplicaciones Go confiables.

Las pruebas unitarias en Go son geniales

¿Por qué las pruebas importan en Go

La filosofía de Go enfatiza la simplicidad y la confiabilidad. La biblioteca estándar incluye el paquete testing, haciendo que las pruebas unitarias sean ciudadanos de primera clase en el ecosistema de Go. El código Go bien probado mejora la mantenibilidad, detecta errores temprano y proporciona documentación a través de ejemplos. Si eres nuevo en Go, consulta nuestro Hoja de trucos de Go para una referencia rápida de los fundamentos del lenguaje.

Beneficios clave de las pruebas en Go:

  • Soporte integrado: No se requieren marcos externos
  • Ejecución rápida: Ejecución concurrente de pruebas por defecto
  • Sintaxis simple: Poco código de plantilla
  • Herramientas ricas: Informes de cobertura, benchmarks y perfilado
  • Amigable con CI/CD: Integración fácil con pipelines automatizados

Estructura del proyecto para pruebas en Go

Las pruebas en Go viven junto a tu código de producción con una convención clara de nombres:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Convenios clave:

  • Los archivos de prueba terminan con _test.go
  • Las pruebas están en el mismo paquete que el código (o usan el sufijo _test para pruebas de caja negra)
  • Cada archivo de origen puede tener un archivo de prueba correspondiente

Enfoques de pruebas por paquete

Pruebas de caja blanca (mismo paquete):

package calculator

import "testing"
// Puede acceder a funciones y variables no exportadas

Pruebas de caja negra (paquete externo):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Solo puede acceder a funciones exportadas (recomendado para APIs públicas)

Estructura básica de las pruebas

Cada función de prueba sigue este patrón:

package calculator

import "testing"

// La función de prueba debe comenzar con "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Métodos de testing.T:

  • t.Error() / t.Errorf(): Marca la prueba como fallida pero continúa
  • t.Fatal() / t.Fatalf(): Marca la prueba como fallida y detiene inmediatamente
  • t.Log() / t.Logf(): Registra salida (solo mostrada con la bandera -v)
  • t.Skip() / t.Skipf(): Salta la prueba
  • t.Parallel(): Ejecuta la prueba en paralelo con otras pruebas en paralelo

Pruebas basadas en tablas: La forma idiomática de Go

Las pruebas basadas en tablas son el enfoque idiomático de Go para probar múltiples escenarios. Con Generics en Go, también puedes crear ayudantes de prueba seguros de tipo que funcionen con diferentes tipos de datos:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"adición", 2, 3, "+", 5, false},
        {"sustracción", 5, 3, "-", 2, false},
        {"multiplicación", 4, 3, "*", 12, false},
        {"división", 10, 2, "/", 5, false},
        {"división por cero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            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)
            }
        })
    }
}

Ventajas:

  • Una sola función de prueba para múltiples escenarios
  • Fácil de agregar nuevos casos de prueba
  • Documentación clara del comportamiento esperado
  • Mejor organización y mantenibilidad de las pruebas

Ejecutar pruebas

Comandos básicos

# Ejecutar pruebas en el directorio actual
go test

# Ejecutar pruebas con salida detallada
go test -v

# Ejecutar pruebas en todos los subdirectorios
go test ./...

# Ejecutar una prueba específica
go test -run TestAdd

# Ejecutar pruebas que coinciden con un patrón
go test -run TestCalculate/adición

# Ejecutar pruebas en paralelo (por defecto es GOMAXPROCS)
go test -parallel 4

# Ejecutar pruebas con tiempo de espera
go test -timeout 30s

Cobertura de pruebas

# Ejecutar pruebas con cobertura
go test -cover

# Generar perfil de cobertura
go test -coverprofile=coverage.out

# Ver cobertura en el navegador
go tool cover -html=coverage.out

# Mostrar cobertura por función
go tool cover -func=coverage.out

# Establecer modo de cobertura (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Banderas útiles

  • -short: Ejecutar pruebas marcadas con if testing.Short()
  • -race: Habilitar el detector de carrera (encuentra problemas de acceso concurrente)
  • -cpu: Especificar valores de GOMAXPROCS
  • -count n: Ejecutar cada prueba n veces
  • -failfast: Detener en la primera falla de prueba

Ayudantes de prueba y configuración/limpieza

Funciones ayudantes

Marcar funciones ayudantes con t.Helper() para mejorar el informe de errores:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Esta línea se reporta como el llamador
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // La línea de error apunta aquí
}

Configuración y limpieza

func TestMain(m *testing.M) {
    // Código de configuración aquí
    setup()
    
    // Ejecutar pruebas
    code := m.Run()
    
    // Código de limpieza aquí
    teardown()
    
    os.Exit(code)
}

Casos de prueba

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Código de prueba aquí
}

Mocking y Inyección de dependencias

Mocking basado en interfaces

Cuando pruebas código que interactúa con bases de datos, usar interfaces facilita la creación de implementaciones de mock. Si estás trabajando con PostgreSQL en Go, consulta nuestra comparación de ORMs en Go para elegir la biblioteca de base de datos adecuada con buena testabilidad.

// Código de producción
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Código de prueba
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("usuario no encontrado")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("error inesperado: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Bibliotecas de prueba populares

Testify

La biblioteca de prueba más popular en Go para afirmaciones y mocks:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "deberían ser iguales")
    assert.NotNil(t, result)
}

// Ejemplo de mock
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Otras herramientas

  • gomock: Marco de mocking de Google con generación de código
  • httptest: Biblioteca estándar para probar manejadores HTTP
  • testcontainers-go: Pruebas de integración con contenedores Docker
  • ginkgo/gomega: Marco de prueba estilo BDD

Cuando pruebas integraciones con servicios externos como modelos de IA, necesitarás mockear o stubear esas dependencias. Por ejemplo, si estás usando Ollama en Go, considera crear envolturas de interfaz para hacer tu código más testable.

Pruebas de benchmark

Go incluye soporte integrado para benchmarks:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Ejecutar benchmarks
// go test -bench=. -benchmem

La salida muestra iteraciones por segundo y asignaciones de memoria.

Mejores prácticas

  1. Escribe pruebas basadas en tablas: Usa el patrón de slice de structs para múltiples casos de prueba
  2. Usa t.Run para subpruebas: Mejor organización y se pueden ejecutar subpruebas selectivamente
  3. Prueba primero las funciones exportadas: Enfócate en el comportamiento de la API pública
  4. Mantén las pruebas simples: Cada prueba debe verificar una sola cosa
  5. Usa nombres significativos para las pruebas: Describe qué se está probando y el resultado esperado
  6. No pruebes detalles de implementación: Prueba el comportamiento, no los internos
  7. Usa interfaces para dependencias: Facilita el mocking
  8. Busca alta cobertura, pero calidad sobre cantidad: La cobertura del 100% no significa que esté libre de errores
  9. Ejecuta pruebas con la bandera -race: Detecta problemas de concurrencia temprano
  10. Usa TestMain para configuración costosa: Evita repetir la configuración en cada prueba

Ejemplo: Suite de pruebas completa

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("el nombre no puede estar vacío")
    }
    if u.Email == "" {
        return errors.New("el correo electrónico no puede estar vacío")
    }
    return nil
}

// Archivo de prueba: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "usuario válido",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "nombre vacío",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "el nombre no puede estar vacío",
        },
        {
            name:    "correo electrónico vacío",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "el correo electrónico no puede estar vacío",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() mensaje de error = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Enlaces útiles

Conclusión

El marco de pruebas de Go proporciona todo lo necesario para pruebas unitarias completas con mínima configuración. Siguiendo los idiomas de Go como pruebas basadas en tablas, usando interfaces para mocking y aprovechando las herramientas integradas, puedes crear suites de pruebas mantenibles y confiables que crecen con tu base de código.

Estas prácticas de prueba se aplican a todos los tipos de aplicaciones en Go, desde servicios web hasta aplicaciones CLI construidas con Cobra & Viper. Probar herramientas de línea de comandos requiere patrones similares con un enfoque adicional en la prueba de entrada/salida y el análisis de banderas.

Comienza con pruebas simples, añade gradualmente cobertura y recuerda que probar es una inversión en la calidad del código y la confianza del desarrollador. El énfasis de la comunidad de Go en las pruebas hace más fácil mantener proyectos a largo plazo y colaborar eficazmente con compañeros.