Testing avanzado en Go: table-driven tests, subtests, benchmarks y testify

El ecosistema de testing de Go está integrado en el propio toolchain: no hace falta un framework externo para escribir tests de calidad. Con go test, table-driven tests, subtests y benchmarks tienes todo lo necesario para la mayoría de proyectos. La librería testify añade aserciones más expresivas cuando las necesitas.

Table-driven tests con t.Run

El patrón más idiomático en Go: una tabla de casos y un bucle que los ejecuta como subtests independientes:

package calculadora_test

import (
    "testing"
    "example.com/calculadora"
)

func TestDividir(t *testing.T) {
    casos := []struct {
        nombre    string
        a, b      float64
        esperado  float64
        debeError bool
    }{
        {"positivos", 10, 2, 5, false},
        {"negativo dividendo", -10, 2, -5, false},
        {"division por cero", 10, 0, 0, true},
        {"decimales", 7, 2, 3.5, false},
    }

    for _, tc := range casos {
        t.Run(tc.nombre, func(t *testing.T) {
            resultado, err := calculadora.Dividir(tc.a, tc.b)
            if tc.debeError {
                if err == nil {
                    t.Error("esperaba error, no hubo ninguno")
                }
                return
            }
            if err != nil {
                t.Fatalf("error inesperado: %v", err)
            }
            if resultado != tc.esperado {
                t.Errorf("Dividir(%v, %v) = %v, esperado %v", tc.a, tc.b, resultado, tc.esperado)
            }
        })
    }
}

t.Parallel: tests concurrentes

Llamar a t.Parallel() al principio de un subtest indica que puede ejecutarse en paralelo con otros tests paralelos. Reduce el tiempo total de ejecución en suites grandes:

func TestAPIExterna(t *testing.T) {
    endpoints := []string{"/usuarios", "/productos", "/pedidos"}

    for _, ep := range endpoints {
        ep := ep // captura la variable (Go < 1.22)
        t.Run(ep, func(t *testing.T) {
            t.Parallel()
            resp, err := http.Get("https://api.test" + ep)
            if err != nil {
                t.Fatal(err)
            }
            resp.Body.Close()
            if resp.StatusCode != 200 {
                t.Errorf("esperado 200, obtenido %d", resp.StatusCode)
            }
        })
    }
}

t.Helper y t.Cleanup

t.Helper() marca una función como auxiliar, de modo que los fallos señalan el punto de llamada, no el interior del helper. t.Cleanup registra funciones que se ejecutan al terminar el test:

func crearBDTest(t *testing.T) *sql.DB {
    t.Helper() // los errores señalarán al test que llama, no aquí

    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("abrir BD: %v", err)
    }

    t.Cleanup(func() {
        db.Close() // se ejecuta cuando el test termina
    })

    // Crear esquema
    db.Exec("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nombre TEXT)")
    return db
}

func TestRepositorio(t *testing.T) {
    db := crearBDTest(t)
    repo := NewRepositorio(db)

    _, err := repo.Crear("Ana")
    if err != nil {
        t.Fatal(err)
    }
}

Benchmarks con b.N

Go ejecuta el benchmark tantas veces como necesite para obtener una medida estable. b.N aumenta automáticamente en cada iteración:

func BenchmarkConcatenar(b *testing.B) {
    palabras := []string{"hola", "mundo", "en", "go"}

    b.Run("strings.Join", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = strings.Join(palabras, " ")
        }
    })

    b.Run("strings.Builder", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            for j, p := range palabras {
                if j > 0 {
                    sb.WriteByte(' ')
                }
                sb.WriteString(p)
            }
            _ = sb.String()
        }
    })
}
// go test -bench=. -benchmem

Testify: aserciones más expresivas

testify no reemplaza el testing de Go pero añade aserciones legibles que reducen el código repetitivo:

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestServicio(t *testing.T) {
    svc := NewServicio()

    usuario, err := svc.CrearUsuario("[email protected]")

    require.NoError(t, err)              // para si hay error
    assert.NotNil(t, usuario)
    assert.Equal(t, "[email protected]", usuario.Email)
    assert.Greater(t, usuario.ID, 0)
}

testify/mock: mocks tipados

type RepositorioMock struct {
    mock.Mock
}

func (m *RepositorioMock) Guardar(u *Usuario) error {
    args := m.Called(u)
    return args.Error(0)
}

func TestServicioConMock(t *testing.T) {
    repo := new(RepositorioMock)
    repo.On("Guardar", mock.AnythingOfType("*main.Usuario")).Return(nil)

    svc := NewServicio(repo)
    err := svc.Registrar("[email protected]")
    assert.NoError(t, err)
    repo.AssertExpectations(t)
}

testing/fstest: simular sistemas de ficheros

func TestLeerConfig(t *testing.T) {
    fs := fstest.MapFS{
        "config.yaml": {Data: []byte("puerto: 8080ndebug: true")},
    }

    cfg, err := LeerConfig(fs, "config.yaml")
    require.NoError(t, err)
    assert.Equal(t, 8080, cfg.Puerto)
}

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP