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.

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:
- Está en un fichero que termina en
_test.go. - Su nombre empieza por
Test. - 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.001sSin output adicional si todo pasa. Si algo falla:
--- FAIL: TestAdd (0.00s)
math_test.go:8: Add(2, 3) = 6; want 5
FAILHay tres cosas que vale la pena notar:
- No hay asserts. Go no tiene
assertEqualsniassertThaten la librería estándar. Usasify llamas at.Errorfot.Fatalf. Esto parece primitivo, pero tiene una ventaja: los mensajes de error son tuyos y dicen exactamente lo que quieres que digan. t.Errorfno detiene el test. Si quieres que el test pare inmediatamente al fallar, usat.Fatalf.Errorfregistra el fallo y sigue ejecutando. Esto es útil cuando quieres ver todos los fallos de golpe.- El fichero
_test.gose 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_testUsar 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_emailEsto 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.Requestsin abrir ninguna conexión. Puedes configurar headers, body, query params, todo.httptest.NewRecorder: implementahttp.ResponseWritery 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
assertyrequirede testify cuando el boilerplate deif/t.Errorfte ralentice. - Evita
suitede testify. Intenta replicar el setup/teardown basado en clases de xUnit y va contra la filosofía de Go. UnTestMaino 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:
- Repositorios: Tests de integración con testcontainers contra base de datos real.
- Servicios: Tests unitarios con mocks del repositorio.
- Handlers: Tests unitarios con
httptesty mocks del servicio. - 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.outLa 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.outcalc/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/opb.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
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.
Medio: tests unitarios de handlers HTTP. Con
httptest.NewRecordery mocks de servicios. Verificas status codes, headers, formato de respuesta, manejo de errores HTTP. No verificas lógica de negocio aquí.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.
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 e2eTu 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.htmlQué 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.Marshalserializa 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.


