Pruebas de microservicios en Go en 2026: pirámide de tests, mocks, pruebas de integración con Docker
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.goen 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
-raceen 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:
- Sigue la pirámide de tests: la mayoría deben ser tests unitarios rápidos; los de integración, para los caminos críticos.
- Usa mockery para generar mocks de interfaces y evita escribir fakes a mano.
- Levanta PostgreSQL y Redis reales con testcontainers-go — esto elimina toda una clase de bugs que los mocks no detectarán.
- Ejecuta siempre el race detector en el pipeline de CI/CD.
- 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í →