Testing en Go para APIs backend: unitarios, integración y mocks sin volverse loco

Testing en Go: unitarios, table-driven tests, httptest, mocks con interfaces e integración. Enfoque pragmático para backend real.

Cover for Testing en Go para APIs backend: unitarios, integración y mocks sin volverse loco

La historia de testing en Go es refrescantemente simple: no hay JUnit, no hay pytest, no hay test runner externo. Solo go test y el paquete testing de la librería estándar. No necesitas instalar nada, configurar nada, ni discutir con tu equipo qué framework de testing usar.

Viniendo de Java o Python, esto parece demasiado austero. Y lo es, deliberadamente. Go apuesta por que las herramientas de testing sean tan simples que no tengas excusa para no escribir tests. Y funciona: la mayoría de proyectos Go que encuentras en GitHub tienen tests desde el primer commit. No porque sus autores sean más disciplinados, sino porque la barrera de entrada es casi inexistente.

Ahora, hay una trampa. Que sea fácil empezar a testear no significa que sea fácil testear bien. He visto (y escrito) tests en Go que eran más complicados que el código que estaban probando. Tests que rompían con cada refactor. Tests que pasaban siempre, incluso cuando el código tenía bugs. Tests que tardaban 5 minutos en ejecutar porque levantaban media infraestructura.

Este artículo va de testing pragmático para APIs backend. No voy a cubrir el 100% de la API del paquete testing. Voy a cubrir lo que realmente necesitas para testear una API REST con Go en producción sin que tus tests se conviertan en una segunda aplicación.


Anatomía de un test en Go

Un test en Go es una función que cumple tres reglas:

  1. Está en un fichero que termina en _test.go.
  2. Su nombre empieza por Test.
  3. Recibe un único parámetro *testing.T.

Eso es todo. No hay anotaciones, no hay decoradores, no hay clases base.

// math.go
package calc

func Add(a, b int) int {
    return a + b
}
// math_test.go
package calc

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

Lo ejecutas con el comando go:

go test ./...

Y obtienes:

ok      calc    0.001s

Sin output adicional si todo pasa. Si algo falla:

--- FAIL: TestAdd (0.00s)
    math_test.go:8: Add(2, 3) = 6; want 5
FAIL

Hay tres cosas que vale la pena notar:

  • No hay asserts. Go no tiene assertEquals ni assertThat en la librería estándar. Usas if y llamas a t.Errorf o t.Fatalf. Esto parece primitivo, pero tiene una ventaja: los mensajes de error son tuyos y dicen exactamente lo que quieres que digan.
  • t.Errorf no detiene el test. Si quieres que el test pare inmediatamente al fallar, usa t.Fatalf. Errorf registra el fallo y sigue ejecutando. Esto es útil cuando quieres ver todos los fallos de golpe.
  • El fichero _test.go se compila solo al ejecutar tests. No entra en tu binario de producción. Puedes poner helpers, mocks y datos de prueba ahí sin preocuparte.

El paquete de test: misma o diferente

Puedes declarar el paquete del test de dos formas:

// Mismo paquete: acceso a funciones no exportadas
package calc
// Paquete externo: solo acceso a la API pública
package calc_test

Usar package calc_test te obliga a testear solo lo exportado, que es exactamente lo que deberías hacer la mayoría de veces. Si necesitas testear funciones internas, usa el mismo paquete, pero piénsalo dos veces: si no puedes testear algo a través de su API pública, quizás la API está mal diseñada.


Table-driven tests: el patrón que define Go

Si hay un patrón de testing que define a Go, es este. Los table-driven tests eliminan la duplicación agrupando casos de prueba en un slice y ejecutándolos en bucle.

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed signs", -1, 5, 4},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Esto no es una convención opcional. Es EL estándar en Go. Lo encontrarás en la librería estándar, en proyectos de la comunidad, en cada code review. Si escribes tests en Go y no usas table-driven tests, alguien te lo va a señalar.

Las ventajas son claras:

  • Añadir un caso nuevo es una línea. No una función nueva, no un método nuevo. Una línea en el slice.
  • Cada caso tiene nombre. Cuando falla, sabes exactamente cuál.
  • El código de verificación se escribe una sola vez. Si cambias cómo verificas, lo cambias en un sitio.
  • Es fácil cubrir edge cases. Psicológicamente, añadir un caso más a una tabla es mucho más fácil que escribir otra función de test.

Un error común es abusar de ellos. Si cada caso necesita setup diferente, lógica de verificación diferente y teardown diferente, una tabla no es el patrón correcto. Usa funciones de test separadas.


Subtests con t.Run

Ya viste t.Run en el ejemplo anterior. Merece su propia sección porque es más potente de lo que parece.

func TestUserService(t *testing.T) {
    t.Run("Create", func(t *testing.T) {
        t.Run("with valid data", func(t *testing.T) {
            // ...
        })
        t.Run("with duplicate email", func(t *testing.T) {
            // ...
        })
    })

    t.Run("Delete", func(t *testing.T) {
        t.Run("existing user", func(t *testing.T) {
            // ...
        })
        t.Run("nonexistent user", func(t *testing.T) {
            // ...
        })
    })
}

La salida es jerárquica:

--- FAIL: TestUserService/Create/with_duplicate_email (0.00s)

Puedes ejecutar un subtest concreto:

go test -run TestUserService/Create/with_duplicate_email

Esto es extremadamente útil cuando tienes un test largo que falla y quieres iterar rápido sobre el caso concreto sin ejecutar los 200 casos de la tabla.

Otro uso importante: subtests paralelos. Si tus subtests son independientes entre sí, puedes ejecutarlos en paralelo:

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := SlowOperation(tt.input)
        if result != tt.expected {
            t.Errorf("got %v; want %v", result, tt.expected)
        }
    })
}

t.Parallel() marca el subtest para ejecución concurrente. Go se encarga del scheduling. Pero cuidado: si tus tests comparten estado (una base de datos, un fichero, una variable global), ejecutarlos en paralelo va a producir flaky tests que te quitarán el sueño.


httptest: testear handlers HTTP sin servidor

Aquí es donde el testing en Go empieza a diferenciarse de verdad. El paquete net/http/httptest te permite testear handlers HTTP sin levantar un servidor real. Sin puertos, sin conexiones de red, sin condiciones de carrera por puertos ocupados.

Supongamos un handler que devuelve una lista de tareas:

type TaskHandler struct {
    service TaskService
}

func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.service.GetAll(r.Context())
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(tasks)
}

El test:

func TestListTasks(t *testing.T) {
    // Prepara el mock del servicio
    svc := &mockTaskService{
        tasks: []Task{
            {ID: 1, Title: "Buy milk"},
            {ID: 2, Title: "Deploy v2"},
        },
    }
    handler := &TaskHandler{service: svc}

    // Crea request y recorder
    req := httptest.NewRequest("GET", "/tasks", nil)
    rec := httptest.NewRecorder()

    // Ejecuta el handler directamente
    handler.ListTasks(rec, req)

    // Verifica
    res := rec.Result()
    if res.StatusCode != http.StatusOK {
        t.Fatalf("status = %d; want 200", res.StatusCode)
    }

    contentType := res.Header.Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; want application/json", contentType)
    }

    var tasks []Task
    if err := json.NewDecoder(res.Body).Decode(&tasks); err != nil {
        t.Fatalf("failed to decode body: %v", err)
    }
    if len(tasks) != 2 {
        t.Errorf("got %d tasks; want 2", len(tasks))
    }
}

Las dos piezas clave son:

  • httptest.NewRequest: crea un *http.Request sin abrir ninguna conexión. Puedes configurar headers, body, query params, todo.
  • httptest.NewRecorder: implementa http.ResponseWriter y captura todo lo que el handler escribe: status code, headers, body.

Esto es mucho más rápido que levantar un servidor real. Y más determinista: no hay timeouts de red, no hay puertos ocupados, no hay delays de TCP.

Cuando sí necesitas un servidor de test

A veces necesitas testear middleware, routing o el stack HTTP completo. Para eso, httptest.NewServer:

func TestFullStack(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    })

    server := httptest.NewServer(mux)
    defer server.Close()

    resp, err := http.Get(server.URL + "/health")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d; want 200", resp.StatusCode)
    }
}

httptest.NewServer arranca un servidor HTTP real en un puerto aleatorio. Es más lento que el recorder, pero te da una URL real contra la que hacer peticiones con http.Client. Úsalo cuando necesites testear el comportamiento de red real, no para tests unitarios de handlers.


Mocking con interfaces: sin frameworks

En Java, necesitas Mockito. En Python, tienes unittest.mock. En Go, necesitas… interfaces.

El patrón es simple: tu código depende de una interfaz, no de una implementación concreta. En el test, pasas una implementación fake que controlas.

// service.go
type TaskRepository interface {
    GetAll(ctx context.Context) ([]Task, error)
    GetByID(ctx context.Context, id int) (Task, error)
    Create(ctx context.Context, t Task) (Task, error)
    Delete(ctx context.Context, id int) error
}

type TaskService struct {
    repo TaskRepository
}

func NewTaskService(repo TaskRepository) *TaskService {
    return &TaskService{repo: repo}
}

func (s *TaskService) GetAll(ctx context.Context) ([]Task, error) {
    return s.repo.GetAll(ctx)
}
// service_test.go
type mockRepo struct {
    tasks []Task
    err   error
}

func (m *mockRepo) GetAll(ctx context.Context) ([]Task, error) {
    return m.tasks, m.err
}

func (m *mockRepo) GetByID(ctx context.Context, id int) (Task, error) {
    for _, t := range m.tasks {
        if t.ID == id {
            return t, nil
        }
    }
    return Task{}, fmt.Errorf("not found")
}

func (m *mockRepo) Create(ctx context.Context, t Task) (Task, error) {
    t.ID = len(m.tasks) + 1
    m.tasks = append(m.tasks, t)
    return t, m.err
}

func (m *mockRepo) Delete(ctx context.Context, id int) error {
    return m.err
}

Y el test:

func TestGetAll_ReturnsTasksFromRepo(t *testing.T) {
    repo := &mockRepo{
        tasks: []Task{
            {ID: 1, Title: "Task 1"},
            {ID: 2, Title: "Task 2"},
        },
    }
    svc := NewTaskService(repo)

    tasks, err := svc.GetAll(context.Background())
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(tasks) != 2 {
        t.Errorf("got %d tasks; want 2", len(tasks))
    }
}

func TestGetAll_PropagatesRepoError(t *testing.T) {
    repo := &mockRepo{
        err: fmt.Errorf("database connection lost"),
    }
    svc := NewTaskService(repo)

    _, err := svc.GetAll(context.Background())
    if err == nil {
        t.Fatal("expected error, got nil")
    }
}

No hay magia. No hay reflexión. No hay código generado. Defines una interfaz, creas un struct que la implementa con los valores que necesitas, y lo pasas. Es más verboso que Mockito, pero es infinitamente más fácil de entender y depurar.

Interfaces pequeñas, mocks fáciles

Este patrón funciona genial cuando las interfaces son pequeñas. Una interfaz de 2-3 métodos es trivial de mockear a mano. Una interfaz de 15 métodos es un infierno.

Si te encuentras escribiendo mocks enormes donde solo implementas 1 de los 12 métodos y el resto son panic("not implemented"), la interfaz es demasiado grande. Divide. Go favorece interfaces pequeñas: io.Reader, io.Writer, fmt.Stringer tienen un solo método.

// En vez de esto
type UserService interface {
    Create(ctx context.Context, u User) error
    GetByID(ctx context.Context, id int) (User, error)
    GetByEmail(ctx context.Context, email string) (User, error)
    Update(ctx context.Context, u User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context, filter Filter) ([]User, error)
    Count(ctx context.Context) (int, error)
    Activate(ctx context.Context, id int) error
    Deactivate(ctx context.Context, id int) error
}

// Si tu handler solo necesita listar y obtener por ID:
type UserReader interface {
    GetByID(ctx context.Context, id int) (User, error)
    List(ctx context.Context, filter Filter) ([]User, error)
}

Tu handler depende de UserReader, no de UserService. Tu mock implementa 2 métodos en vez de 9. Todo es más simple.


Cuándo usar testify

La librería estándar de Go no tiene asserts. Eso es una decisión de diseño deliberada, pero después de escribir tu centésimo if got != want { t.Errorf(...) }, empiezas a entender por qué existe testify.

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

func TestWithTestify(t *testing.T) {
    result, err := DoSomething()

    // require detiene el test si falla (como t.Fatal)
    require.NoError(t, err)
    require.NotNil(t, result)

    // assert registra el fallo pero sigue (como t.Error)
    assert.Equal(t, "expected", result.Name)
    assert.Len(t, result.Items, 3)
    assert.Contains(t, result.Tags, "important")
}

Testify reduce el boilerplate y los mensajes de error son más descriptivos automáticamente. Pero tiene un coste: es una dependencia externa. En un proyecto con 10 tests no merece la pena. En una API con 500 tests, probablemente sí.

Mi criterio:

  • Usa la librería estándar para proyectos pequeños, librerías públicas, y cuando quieras cero dependencias externas.
  • Usa assert y require de testify cuando el boilerplate de if/t.Errorf te ralentice.
  • Evita suite de testify. Intenta replicar el setup/teardown basado en clases de xUnit y va contra la filosofía de Go. Un TestMain o un helper function hacen lo mismo sin la complejidad.

Un patrón intermedio que funciona bien: escribir helpers propios sin depender de testify.

func assertEqual[T comparable](t *testing.T, got, want T) {
    t.Helper()
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

t.Helper() es clave: hace que cuando el test falla, el error apunte a la línea que llamó a assertEqual, no a la línea dentro de la función helper. Sin t.Helper(), todos tus errores señalarían a la misma línea del helper, que es inútil para depurar.


Tests de integración: build tags y testcontainers

Los tests unitarios están bien para lógica de negocio, pero si tu API habla con PostgreSQL, Kafka, Redis o cualquier sistema externo, necesitas tests de integración. Y necesitas poder separarlos de los unitarios.

Build tags para separar tests

La forma estándar de separar tests en Go son los build tags:

//go:build integration

package repository

import (
    "context"
    "testing"
)

func TestPostgresRepo_Create(t *testing.T) {
    db := setupTestDB(t)
    repo := NewPostgresRepo(db)

    task, err := repo.Create(context.Background(), Task{Title: "Test"})
    if err != nil {
        t.Fatalf("Create failed: %v", err)
    }
    if task.ID == 0 {
        t.Error("expected non-zero ID")
    }
}

Los tests con //go:build integration no se ejecutan con go test ./.... Tienes que pedirlos explícitamente:

# Solo unitarios (por defecto)
go test ./...

# Solo integración
go test -tags=integration ./...

# Todo
go test -tags=integration ./...

Esto es fundamental para CI. Tus tests unitarios corren en segundos y se ejecutan en cada push. Los de integración necesitan infraestructura (base de datos, cola de mensajes) y se ejecutan en un pipeline separado o con un paso previo de setup.

Testcontainers: infraestructura descartable

Testcontainers arranca contenedores Docker desde tus tests. Base de datos real, datos reales, sin mocks.

//go:build integration

package repository

import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgres(t *testing.T) string {
    t.Helper()
    ctx := context.Background()

    container, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    if err != nil {
        t.Fatalf("failed to start postgres: %v", err)
    }

    t.Cleanup(func() {
        if err := container.Terminate(ctx); err != nil {
            t.Logf("failed to terminate container: %v", err)
        }
    })

    connStr, err := container.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("failed to get connection string: %v", err)
    }

    return connStr
}

func TestPostgresRepo_Integration(t *testing.T) {
    connStr := setupPostgres(t)
    db := connectDB(t, connStr)
    runMigrations(t, db)

    repo := NewPostgresRepo(db)

    t.Run("Create and GetByID", func(t *testing.T) {
        created, err := repo.Create(context.Background(), Task{
            Title: "Integration test task",
        })
        if err != nil {
            t.Fatalf("Create: %v", err)
        }

        got, err := repo.GetByID(context.Background(), created.ID)
        if err != nil {
            t.Fatalf("GetByID: %v", err)
        }

        if got.Title != "Integration test task" {
            t.Errorf("title = %q; want %q", got.Title, "Integration test task")
        }
    })
}

t.Cleanup es tu amigo aquí. Registra una función que se ejecuta al terminar el test (o subtest), independientemente de si pasa o falla. Es el equivalente a defer pero para el ciclo de vida del test. Úsalo para limpiar contenedores, cerrar conexiones, borrar ficheros temporales.

El patrón que recomiendo para una API backend:

  1. Repositorios: Tests de integración con testcontainers contra base de datos real.
  2. Servicios: Tests unitarios con mocks del repositorio.
  3. Handlers: Tests unitarios con httptest y mocks del servicio.
  4. End-to-end: Uno o dos tests que levantan todo el stack (opcional pero recomendable).

Test coverage: go test -cover

Go tiene cobertura de tests integrada. No necesitas instalar nada:

# Muestra porcentaje de cobertura
go test -cover ./...

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

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

La visualización HTML es muy útil: te muestra línea por línea qué está cubierto (verde) y qué no (rojo). Es la forma más rápida de identificar ramas no testeadas.

# Cobertura por función
go tool cover -func=coverage.out
calc/math.go:3:     Add         100.0%
calc/math.go:7:     Divide      75.0%
total:              (statements) 87.5%

Ahora, un tema controvertido: el porcentaje de cobertura no es una métrica de calidad. He visto proyectos con 95% de cobertura donde los tests no verificaban nada (solo ejecutaban el código sin comprobar resultados). Y proyectos con 60% de cobertura donde cada test era sólido.

Mi enfoque pragmático:

  • Apunta a cubrir los caminos críticos. Los happy paths y los errores que un usuario real puede provocar.
  • No persigas el 100%. El esfuerzo para pasar del 85% al 100% rara vez compensa.
  • Usa la cobertura como herramienta exploratoria, no como gate en CI. Ver qué está en rojo te ayuda a decidir qué testear, pero un número no debería bloquear un merge.

Benchmarks con testing.B

Go tiene benchmarks integrados en el mismo paquete testing. Si necesitas medir rendimiento, no necesitas herramientas externas.

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

Ejecución:

go test -bench=. -benchmem ./...
BenchmarkAdd-8    1000000000    0.2900 ns/op    0 B/op    0 allocs/op

b.N es el número de iteraciones que Go decide ejecutar para obtener una medición estable. No lo fijas tú. -benchmem añade información de allocations, que suele ser más útil que el tiempo bruto para optimizar código Go.

Un benchmark más realista para una API:

func BenchmarkJSONEncoding(b *testing.B) {
    tasks := make([]Task, 100)
    for i := range tasks {
        tasks[i] = Task{ID: i, Title: fmt.Sprintf("Task %d", i)}
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        json.NewEncoder(&buf).Encode(tasks)
    }
}

b.ResetTimer() descarta el tiempo del setup. Úsalo siempre que tengas inicialización antes del bucle de benchmark.

Los benchmarks son útiles para comparar implementaciones, detectar regresiones de rendimiento y entender el coste de allocations. Pero no los escribas para todo. Si tu endpoint tarda 200ms porque espera a la base de datos, un benchmark que demuestre que tu serialización JSON tarda 2 microsegundos no aporta información relevante.


Errores comunes que destrozan tus tests

Después de años escribiendo y revisando tests en Go, estos son los patrones que he visto causar más dolor:

Testear detalles de implementación

// MAL: test acoplado a la implementación interna
func TestService_CallsRepoExactlyOnce(t *testing.T) {
    repo := &countingMockRepo{}
    svc := NewService(repo)
    svc.GetUser(context.Background(), 1)

    if repo.callCount != 1 {
        t.Errorf("repo called %d times; want 1", repo.callCount)
    }
}

Este test se rompe si añades caching, si cambias de un Get a un BatchGet, o si simplemente refactorizas. Testea el resultado, no el camino.

// BIEN: testea el comportamiento observable
func TestService_ReturnsUser(t *testing.T) {
    repo := &mockRepo{user: User{ID: 1, Name: "Alice"}}
    svc := NewService(repo)

    user, err := svc.GetUser(context.Background(), 1)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %q; want %q", user.Name, "Alice")
    }
}

Over-mocking

Cuando mockeas tres capas de abstracción para testear una función que suma dos números, algo ha ido muy mal. Si tu test necesita configurar 15 mocks para funcionar, no es un test unitario: es una demostración de que tu código tiene demasiadas dependencias.

Regla simple: si el mock es más complejo que el código real, usa el código real.

Tests que ignoran errores

// MAL: si Create devuelve error, el test pasa igualmente
func TestCreateTask(t *testing.T) {
    task, _ := svc.Create(ctx, Task{Title: "Test"})
    if task.Title != "Test" {
        t.Error("wrong title")
    }
}

Si Create devuelve un error y un Task vacío, task.Title es "", el test falla, pero por la razón equivocada. Siempre comprueba errores en los tests.

Tests que dependen del orden

// MAL: si TestA no se ejecuta antes, TestB falla
func TestA_CreateUser(t *testing.T) {
    svc.Create(ctx, User{Email: "test@test.com"})
}

func TestB_GetUser(t *testing.T) {
    user, _ := svc.GetByEmail(ctx, "test@test.com")
    // falla si TestA no se ejecutó primero
}

Cada test debería funcionar de forma aislada. Si necesitas datos, créalos dentro del test o en un setup compartido con TestMain.


Una estrategia de testing pragmática para APIs backend

Después de todo lo anterior, esta es la estrategia que uso y recomiendo para APIs backend en Go. No es la única posible, pero ha funcionado consistentemente en proyectos reales.

La pirámide que funciona

  1. Base: tests unitarios de servicios y lógica de negocio. Rápidos, sin dependencias externas, con mocks de repositorios. Aquí está el grueso de tus tests. Table-driven tests para cubrir variantes. Verificas reglas de negocio, validaciones, transformaciones de datos.

  2. Medio: tests unitarios de handlers HTTP. Con httptest.NewRecorder y mocks de servicios. Verificas status codes, headers, formato de respuesta, manejo de errores HTTP. No verificas lógica de negocio aquí.

  3. Medio-alto: tests de integración de repositorios. Con testcontainers o una base de datos de test. Verificas que tus queries SQL funcionan, que los tipos se mapean correctamente, que las constraints se respetan.

  4. Cima: uno o dos tests E2E que levantan la API completa y hacen peticiones reales. Smoke tests que verifican que todo encaja.

En la práctica

proyecto/
├── handler/
│   ├── task_handler.go
│   └── task_handler_test.go      # httptest, mock service
├── service/
│   ├── task_service.go
│   └── task_service_test.go      # table-driven, mock repo
├── repository/
│   ├── task_repo.go
│   └── task_repo_test.go         # //go:build integration
└── e2e/
    └── api_test.go               # //go:build e2e

Tu Makefile o pipeline de CI:

test:
	go test ./...

test-integration:
	go test -tags=integration ./...

test-all:
	go test -tags="integration e2e" ./...

coverage:
	go test -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html

Qué testear y qué no

Testea:

  • Reglas de negocio y validaciones.
  • Manejo de errores (happy path Y sad paths).
  • Serialización/deserialización si es crítica.
  • Queries SQL complejas (integración).
  • Middleware que afecta seguridad (autenticación, autorización).

No testees:

  • Getters y setters triviales.
  • Código generado.
  • Wrappers de una línea sobre la librería estándar.
  • Que Go funcione correctamente (no testees que json.Marshal serializa JSON).

La pregunta clave

Antes de escribir un test, pregúntate: “Si este test falla, ¿sabré qué se rompió y cómo arreglarlo?”

Si la respuesta es sí, es un buen test. Si la respuesta es “sabré que algo cambió, pero no qué ni por qué”, el test está acoplado a detalles de implementación y te va a dar más trabajo del que te ahorra.

Go te da herramientas simples. testing.T, httptest, interfaces, build tags. No necesitas más para testear una API backend de forma sólida. La dificultad no está en las herramientas, está en decidir qué merece un test y qué no. Y eso, como casi todo en ingeniería de software, es una cuestión de criterio, no de framework.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados