Desarrollo backend

Pruebas de microservicios en Go en 2026: pirámide de tests, mocks, pruebas de integración con Docker

Ruslan Ismailov Publicado 14 min de lectura
P

Introducción: por qué probar microservicios es más complejo que un monolito

La arquitectura de microservicios ofrece flexibilidad y escalabilidad, pero al mismo tiempo complica enormemente las pruebas. En un monolito puedes levantar una sola base de datos, iniciar el servicio y ejecutar todos los tests. En el mundo de los microservicios, cada servicio interactúa con PostgreSQL, Redis, brokers de mensajes, APIs REST de terceros y otros servicios. Cualquiera de estos componentes puede convertirse en una fuente de inestabilidad en las pruebas.

Aquí es donde entra en juego la pirámide de tests. El concepto es sencillo: la base de la pirámide son los tests unitarios, rápidos y económicos; el centro son los tests de integración; y en la cima están los lentos tests end-to-end. En el contexto de microservicios en Go, la pirámide tiene esta forma:

  • Tests unitarios — prueban la lógica de dominio de forma aislada, sin dependencias externas.
  • Tests de integración — verifican la interacción con dependencias reales o contenedorizadas (PostgreSQL, Redis).
  • Tests end-to-end — lanzan toda la pila y verifican escenarios desde el punto de vista del usuario.

En este artículo recorreremos todo el camino: desde la escritura de tests table-driven hasta levantar PostgreSQL en Docker directamente desde el código del test, y veremos cómo integrar todo esto en un pipeline de CI/CD.

Tests unitarios en Go: testify y el enfoque table-driven

Go incluye el paquete testing de la biblioteca estándar, que suele ser suficiente para tests básicos. Sin embargo, en proyectos reales casi siempre se utiliza testify — un conjunto de utilidades para assertions cómodas y tests en suite.

Tests table-driven

El patrón table-driven tests es la forma idiomática de hacer pruebas en Go. En lugar de duplicar código para cada escenario, describes una tabla de datos de entrada y resultados esperados:

package calculator_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/calculator"
)

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

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := calculator.Calculate(tt.a, tt.b, tt.op)
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Prueba de la lógica de dominio

La lógica de dominio pura es el mejor candidato para los tests unitarios. Si tu servicio está correctamente dividido en capas (domain, usecase, repository), las reglas de negocio se pueden probar sin ningún tipo de I/O. Procura que las estructuras de dominio no dependan de frameworks ni paquetes externos — esto hace que los tests sean instantáneos y estables.

Mocks y stubs: generación con mockery

En los microservicios, casi cada caso de uso depende de interfaces: un repositorio para PostgreSQL, una caché de Redis, un cliente HTTP de una API externa. Probar el usecase con dependencias reales es costoso e inestable. Aquí es donde se necesitan los mocks.

Generación de mocks con mockery

La herramienta mockery genera automáticamente implementaciones mock de interfaces de Go. Instalación y uso básico:

# Instalación
go install github.com/vektra/mockery/v2@latest

# Generación de mocks para todas las interfaces del paquete
mockery --all --keeptree --output=./mocks

Supongamos que tienes una interfaz de repositorio de usuarios:

// internal/domain/user.go
package domain

type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
}

Tras la generación, mockery creará la estructura MockUserRepository. El test del usecase tiene este aspecto:

package usecase_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/domain"
    "myapp/internal/usecase"
    "myapp/mocks"
)

func TestGetUser_Success(t *testing.T) {
    mockRepo := mocks.NewUserRepository(t)
    expected := &domain.User{ID: 1, Name: "Alice"}

    mockRepo.On("GetByID", context.Background(), int64(1)).
        Return(expected, nil)

    uc := usecase.NewUserUsecase(mockRepo)
    user, err := uc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expected.Name, user.Name)
    mockRepo.AssertExpectations(t)
}

Cuándo usar un stub en lugar de un mock

Un stub es un objeto más simple que simplemente devuelve una respuesta predefinida sin verificar las llamadas. Usa stubs cuando solo te importa el resultado, y mocks cuando necesitas verificar que la dependencia fue invocada con argumentos concretos un número determinado de veces.

Tests de integración con Docker: testcontainers-go

Los mocks son útiles para los tests unitarios, pero no verifican la interacción real con la base de datos. Las consultas SQL pueden contener errores, las migraciones pueden no aplicarse correctamente, las transacciones pueden comportarse de forma inesperada. Para verificar esta capa se necesitan tests de integración con dependencias reales.

La biblioteca testcontainers-go permite levantar contenedores Docker directamente desde el código del test. Sin archivos docker-compose, sin configuración manual — el contenedor arranca antes del test y se detiene al finalizar.

Test de integración con PostgreSQL

package repository_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
    _ "github.com/lib/pq"
    "database/sql"
    "myapp/internal/repository"
)

func TestUserRepository_Save(t *testing.T) {
    ctx := context.Background()

    // Levantamos el contenedor PostgreSQL
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("testuser"),
        postgres.WithPassword("testpass"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    require.NoError(t, err)
    defer pgContainer.Terminate(ctx)

    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)

    db, err := sql.Open("postgres", connStr)
    require.NoError(t, err)
    defer db.Close()

    // Aplicamos las migraciones
    _, err = db.Exec(`
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            name VARCHAR(255) NOT NULL,
            email VARCHAR(255) UNIQUE NOT NULL
        )
    `)
    require.NoError(t, err)

    repo := repository.NewUserRepository(db)
    user := &domain.User{Name: "Bob", Email: "bob@example.com"}

    err = repo.Save(ctx, user)
    assert.NoError(t, err)
    assert.NotZero(t, user.ID)
}

Pruebas con Redis

De la misma forma, puedes levantar un contenedor de Redis para probar el caché:

package cache_test

import (
    "context"
    "testing"
    "github.com/testcontainers/testcontainers-go/modules/redis"
    "github.com/testcontainers/testcontainers-go"
    "github.com/stretchr/testify/require"
)

func TestCacheRepository(t *testing.T) {
    ctx := context.Background()

    redisContainer, err := redis.RunContainer(ctx,
        testcontainers.WithImage("redis:7-alpine"),
    )
    require.NoError(t, err)
    defer redisContainer.Terminate(ctx)

    endpoint, err := redisContainer.Endpoint(ctx, "")
    require.NoError(t, err)

    // Creamos el cliente Redis y probamos el repositorio de caché
    // ...
}

La ventaja clave de testcontainers-go es que los tests son autosuficientes. El desarrollador clona el repositorio, ejecuta go test ./... y todo funciona sin servicios preinstalados en la máquina.

Pruebas de API REST: httptest y verificación de contratos

Las pruebas de handlers HTTP en Go se resuelven con el paquete estándar net/http/httptest, que permite crear un servidor HTTP en memoria sin conexiones de red reales.

package handler_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/handler"
    "myapp/mocks"
)

func TestCreateUserHandler(t *testing.T) {
    mockUsecase := mocks.NewUserUsecase(t)
    mockUsecase.On("CreateUser", mock.Anything, mock.AnythingOfType("*domain.User")).
        Return(nil)

    h := handler.NewUserHandler(mockUsecase)

    body := `{"name":"Alice","email":"alice@example.com"}`
    req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    h.CreateUser(w, req)

    res := w.Result()
    assert.Equal(t, http.StatusCreated, res.StatusCode)

    var response map[string]interface{}
    json.NewDecoder(res.Body).Decode(&response)
    assert.Equal(t, "Alice", response["name"])
}

Para una verificación más rigurosa de los contratos de API entre servicios, se recomienda considerar herramientas como Pact, que permiten fijar las expectativas del consumidor y verificarlas en el lado del proveedor.

Pruebas de código concurrente

Go es un lenguaje donde la concurrencia está integrada a nivel sintáctico. Las goroutines y los canales son muy poderosos, pero también son fuente de bugs difíciles de detectar. Para encontrarlos, Go incluye el race detector.

Race detector

Ejecuta los tests con el flag -race y Go instrumentará el código para detectar condiciones de carrera:

go test -race ./...

El race detector funciona en tiempo de ejecución y ralentiza la ejecución unas 5–10 veces, pero detecta condiciones de carrera reales. Actívalo siempre en CI.

Prueba de un worker pool

func TestWorkerPool_ProcessesAllJobs(t *testing.T) {
    pool := worker.NewPool(5) // 5 workers
    var processed int64

    for i := 0; i < 100; i++ {
        pool.Submit(func() {
            atomic.AddInt64(&processed, 1)
        })
    }

    pool.Wait()
    assert.Equal(t, int64(100), atomic.LoadInt64(&processed))
}

El uso de sync/atomic y sync.WaitGroup en las pruebas de código concurrente es una práctica obligatoria.

Organización de los tests en el proyecto

Una organización correcta de los tests en un proyecto Go ahorra tiempo y dolores de cabeza al equipo. Esta es la estructura recomendada:

  • Los tests unitarios se ubican junto al código que prueban: user_service_test.go en el mismo paquete o con el sufijo _test.
  • Los tests de integración se trasladan al directorio internal/repository/integration/ o se marcan con un build tag.
  • Los tests E2E viven en un directorio separado test/e2e/.

Build tags para separar los tests

//go:build integration
// +build integration

package repository_test
// Los tests de integración con Docker solo se ejecutan si el tag está presente

Makefile para una ejecución cómoda

.PHONY: test test-unit test-integration test-race

test-unit:
    go test ./... -short -count=1

test-integration:
    go test ./... -tags=integration -count=1 -timeout=120s

test-race:
    go test -race ./... -count=1

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

Integración en CI/CD

Los tests deben ejecutarse automáticamente en cada commit. En 2026, el estándar es GitHub Actions o GitLab CI con ejecución paralela de paquetes de tests.

Ejemplo de workflow con GitHub Actions

name: Go Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache: true
      - name: Run unit tests with race detector
        run: go test -race -short -coverprofile=coverage.out ./...
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.out

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache: true
      - name: Run integration tests
        run: go test -tags=integration -timeout=120s ./...
        env:
          TESTCONTAINERS_RYUK_DISABLED: "false"

Presta atención al uso de la caché de módulos Go (cache: true) — esto acelera considerablemente la descarga de dependencias. Testcontainers-go gestiona los contenedores Docker por sí mismo, por lo que en CI basta con tener el Docker daemon disponible, que está presente en todos los runners estándar de GitHub Actions.

Paralelismo de tests

Usa t.Parallel() en los tests unitarios que no comparten estado. Esto reduce significativamente el tiempo total de ejecución:

func TestSomething(t *testing.T) {
    t.Parallel()
    // test
}

Para los tests de integración con testcontainers, la ejecución paralela también es posible, pero requiere cuidado: cada test debe levantar su propio contenedor o usar uno compartido a través de TestMain.

Antipatrones típicos en las pruebas de servicios Go

  • Probar la implementación en lugar del comportamiento. Si el test verifica que un método del repositorio fue llamado exactamente 3 veces, es frágil. El test debe verificar el resultado de negocio.
  • Estado global en los tests. El uso de variables globales o singletons hace que los tests dependan del orden de ejecución. Crea siempre instancias nuevas de las dependencias en cada test.
  • Demasiados mocks. Si en el test de un usecase hay 7 dependencias mockeadas, es una señal de que el usecase hace demasiado. Descompón la lógica.
  • Ausencia de pruebas para casos límite. Valores nulos, slices vacíos, cadenas muy largas — ahí es donde se esconden los bugs en producción.
  • Ignorar el race detector. Ejecutar tests sin -race en CI supone potenciales data races en producción. Esto es inaceptable en microservicios con workers concurrentes.
  • Tests que dependen de la red externa. Los tests de integración que acceden a servicios externos reales (AWS, Stripe) son inestables. Usa testcontainers o wiremock para aislarlos.
  • Separación débil entre tests unitarios e de integración. Sin build tags o el flag -short, los desarrolladores ejecutan los lentos tests de integración junto con los rápidos tests unitarios, perdiendo retroalimentación inmediata.

Conclusión

Las pruebas exhaustivas de microservicios en Go no consisten en alcanzar un 100% de cobertura por cumplir el expediente. Se trata de tener confianza en el despliegue: cambias el código del repositorio, CI ejecuta los tests, y en pocos minutos sabes que las consultas a PostgreSQL funcionan, que la caché de Redis se invalida correctamente y que los handlers HTTP devuelven los códigos de estado adecuados.

Principios clave que vale la pena adoptar:

  1. Sigue la pirámide de tests: la mayoría deben ser tests unitarios rápidos; los de integración, para los caminos críticos.
  2. Usa mockery para generar mocks de interfaces y evita escribir fakes a mano.
  3. Levanta PostgreSQL y Redis reales con testcontainers-go — esto elimina toda una clase de bugs que los mocks no detectarán.
  4. Ejecuta siempre el race detector en el pipeline de CI/CD.
  5. Separa los tests con tags y targets de Makefile para obtener retroalimentación rápida durante el desarrollo.

La inversión en pruebas bien pensadas se amortiza con creces: menos incidentes en producción, un onboarding más ágil para nuevos desarrolladores y la confianza de refactorizar sin miedo a romper nada.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →