Testing en Go per a APIs backend: unitaris, integració i mocks sense tornar-se boig
Testing en Go: unitaris, table-driven tests, httptest, mocks amb interfícies i integració. Enfocament pragmàtic per a backend real.

La història del testing en Go és refrescantment simple: no hi ha JUnit, no hi ha pytest, no hi ha test runner extern. Només go test i el paquet testing de la llibreria estàndard. No necessites instal·lar res, configurar res, ni discutir amb el teu equip quin framework de testing usar.
Venint de Java o Python, això sembla massa auster. I ho és, deliberadament. Go aposta perquè les eines de testing siguin tan simples que no tinguis excusa per no escriure tests. I funciona: la majoria de projectes Go que trobes a GitHub tenen tests des del primer commit. No perquè els seus autors siguin més disciplinats, sinó perquè la barrera d’entrada és gairebé inexistent.
Ara bé, hi ha una trampa. Que sigui fàcil començar a testejar no significa que sigui fàcil testejar bé. He vist (i escrit) tests en Go que eren més complicats que el codi que estaven provant. Tests que es trencaven amb cada refactor. Tests que sempre passaven, fins i tot quan el codi tenia bugs. Tests que trigaven 5 minuts a executar perquè aixecaven mitja infraestructura.
Aquest article tracta de testing pragmàtic per a APIs backend. No cobreixo el 100% de l’API del paquet testing. Cobriré el que realment necessites per testejar una API REST amb Go en producció sense que els teus tests es converteixin en una segona aplicació.
Anatomia d’un test en Go
Un test en Go és una funció que compleix tres regles:
- Està en un fitxer que acaba en
_test.go. - El seu nom comença per
Test. - Rep un únic paràmetre
*testing.T.
Això és tot. No hi ha anotacions, no hi ha decoradors, no hi ha classes 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)
}
}L’executes amb el comandament go:
go test ./...I obtens:
ok calc 0.001sSense output addicional si tot passa. Si alguna cosa falla:
--- FAIL: TestAdd (0.00s)
math_test.go:8: Add(2, 3) = 6; want 5
FAILHi ha tres coses que val la pena notar:
- No hi ha asserts. Go no té
assertEqualsniassertThata la llibreria estàndard. Usesifi crides at.Errorfot.Fatalf. Això sembla primitiu, però té un avantatge: els missatges d’error són teus i diuen exactament el que vols que diguin. t.Errorfno atura el test. Si vols que el test pari immediatament en fallar, usat.Fatalf.Errorfregistra el fallo i continua executant. Això és útil quan vols veure tots els fallos d’un cop.- El fitxer
_test.goes compila només en executar tests. No entra al teu binari de producció. Pots posar helpers, mocks i dades de prova allà sense preocupar-te.
El paquet de test: mateix o diferent
Pots declarar el paquet del test de dues formes:
// Mateix paquet: accés a funcions no exportades
package calc// Paquet extern: només accés a l'API pública
package calc_testUsar package calc_test t’obliga a testejar només l’exportat, que és exactament el que hauries de fer la majoria de vegades. Si necessites testejar funcions internes, usa el mateix paquet, però pensa-t’ho dues vegades: si no pots testejar alguna cosa a través de la seva API pública, potser l’API està mal dissenyada.
Table-driven tests: el patró que defineix Go
Si hi ha un patró de testing que defineix Go, és aquest. Els table-driven tests eliminen la duplicació agrupant casos de prova en un slice i executant-los 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)
}
})
}
}Això no és una convenció opcional. És L’estàndard en Go. El trobaràs a la llibreria estàndard, en projectes de la comunitat, en cada code review. Si escrius tests en Go i no uses table-driven tests, algú t’ho assenyalarà.
Els avantatges són clars:
- Afegir un cas nou és una línia. No una funció nova, no un mètode nou. Una línia al slice.
- Cada cas té nom. Quan falla, saps exactament quin.
- El codi de verificació s’escriu una sola vegada. Si canvies com verifiques, ho canvies en un lloc.
- És fàcil cobrir edge cases. Psicològicament, afegir un cas més a una taula és molt més fàcil que escriure una altra funció de test.
Un error comú és abusar-ne. Si cada cas necessita setup diferent, lògica de verificació diferent i teardown diferent, una taula no és el patró correcte. Usa funcions de test separades.
Subtests amb t.Run
Ja has vist t.Run a l’exemple anterior. Mereix la seva pròpia secció perquè és més potent del que sembla.
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 sortida és jeràrquica:
--- FAIL: TestUserService/Create/with_duplicate_email (0.00s)Pots executar un subtest concret:
go test -run TestUserService/Create/with_duplicate_emailAixò és extremadament útil quan tens un test llarg que falla i vols iterar ràpidament sobre el cas concret sense executar els 200 casos de la taula.
Un altre ús important: subtests paral·lels. Si els teus subtests són independents entre si, pots executar-los en paral·lel:
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 per a execució concurrent. Go s’encarrega del scheduling. Però compte: si els teus tests comparteixen estat (una base de dades, un fitxer, una variable global), executar-los en paral·lel produirà flaky tests que et llevaran el son.
httptest: testejar handlers HTTP sense servidor
Aquí és on el testing en Go comença a diferenciar-se de debò. El paquet net/http/httptest et permet testejar handlers HTTP sense aixecar un servidor real. Sense ports, sense connexions de xarxa, sense condicions de carrera per ports ocupats.
Suposem un handler que retorna una llista de tasques:
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 servei
svc := &mockTaskService{
tasks: []Task{
{ID: 1, Title: \"Buy milk\"},
{ID: 2, Title: \"Deploy v2\"},
},
}
handler := &TaskHandler{service: svc}
// Crea request i recorder
req := httptest.NewRequest(\"GET\", \"/tasks\", nil)
rec := httptest.NewRecorder()
// Executa el handler directament
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))
}
}Les dues peces clau són:
httptest.NewRequest: crea un*http.Requestsense obrir cap connexió. Pots configurar headers, body, query params, tot.httptest.NewRecorder: implementahttp.ResponseWriteri captura tot el que el handler escriu: status code, headers, body.
Això és molt més ràpid que aixecar un servidor real. I més determinista: no hi ha timeouts de xarxa, no hi ha ports ocupats, no hi ha delays de TCP.
Quan sí necessites un servidor de test
De vegades necessites testejar middleware, routing o el stack HTTP complet. Per a això, 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 arrenca un servidor HTTP real en un port aleatori. És més lent que el recorder, però et dona una URL real contra la qual fer peticions amb http.Client. Usa’l quan necessitis testejar el comportament de xarxa real, no per a tests unitaris de handlers.
Mocking amb interfícies: sense frameworks
A Java, necessites Mockito. A Python, tens unittest.mock. A Go, necessites… interfícies.
El patró és simple: el teu codi depèn d’una interfície, no d’una implementació concreta. Al test, passes una implementació fake que controles.
// 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
}I 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 hi ha màgia. No hi ha reflexió. No hi ha codi generat. Defineixes una interfície, crees un struct que la implementa amb els valors que necessites, i el passes. És més verbós que Mockito, però infinitament més fàcil d’entendre i depurar.
Interfícies petites, mocks fàcils
Aquest patró funciona genial quan les interfícies són petites. Una interfície de 2-3 mètodes és trivial de mockear a mà. Una interfície de 15 mètodes és un infern.
Si et trobes escrivint mocks enormes on només implementes 1 dels 12 mètodes i la resta són panic(\"not implemented\"), la interfície és massa gran. Divideix. Go afavoreix interfícies petites: io.Reader, io.Writer, fmt.Stringer tenen un sol mètode.
// En lloc d'això
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 el teu handler només necessita llistar i obtenir per ID:
type UserReader interface {
GetByID(ctx context.Context, id int) (User, error)
List(ctx context.Context, filter Filter) ([]User, error)
}El teu handler depèn de UserReader, no de UserService. El teu mock implementa 2 mètodes en lloc de 9. Tot és més simple.
Quan usar testify
La llibreria estàndard de Go no té asserts. Això és una decisió de disseny deliberada, però després d’escriure el teu centèsim if got != want { t.Errorf(...) }, comences a entendre per què existeix testify.
import (
\"testing\"
\"github.com/stretchr/testify/assert\"
\"github.com/stretchr/testify/require\"
)
func TestWithTestify(t *testing.T) {
result, err := DoSomething()
// require atura el test si falla (com t.Fatal)
require.NoError(t, err)
require.NotNil(t, result)
// assert registra el fallo però continua (com t.Error)
assert.Equal(t, \"expected\", result.Name)
assert.Len(t, result.Items, 3)
assert.Contains(t, result.Tags, \"important\")
}Testify redueix el boilerplate i els missatges d’error són més descriptius automàticament. Però té un cost: és una dependència externa. En un projecte amb 10 tests no val la pena. En una API amb 500 tests, probablement sí.
El meu criteri:
- Usa la llibreria estàndard per a projectes petits, llibreries públiques, i quan vulguis zero dependències externes.
- Usa
assertirequirede testify quan el boilerplate deif/t.Errorfet faci anar lent. - Evita
suitede testify. Intenta replicar el setup/teardown basat en classes de xUnit i va contra la filosofia de Go. UnTestMaino una helper function fan el mateix sense la complexitat.
Un patró intermedi que funciona bé: escriure helpers propis sense dependre 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() és clau: fa que quan el test falla, l’error apunti a la línia que ha cridat assertEqual, no a la línia dins de la funció helper. Sense t.Helper(), tots els teus errors assenyalarien la mateixa línia del helper, la qual cosa és inútil per depurar.
Tests d’integració: build tags i testcontainers
Els tests unitaris estan bé per a lògica de negoci, però si la teva API parla amb PostgreSQL, Kafka, Redis o qualsevol sistema extern, necessites tests d’integració. I necessites poder separar-los dels unitaris.
Build tags per separar tests
La forma estàndard de separar tests en Go són els 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\")
}
}Els tests amb //go:build integration no s’executen amb go test ./.... Has de demanar-los explícitament:
# Només unitaris (per defecte)
go test ./...
# Només integració
go test -tags=integration ./...
# Tot
go test -tags=integration ./...Això és fonamental per a CI. Els teus tests unitaris corren en segons i s’executen a cada push. Els d’integració necessiten infraestructura (base de dades, cua de missatges) i s’executen en un pipeline separat o amb un pas previ de setup.
Testcontainers: infraestructura descartable
Testcontainers arrenca contenidors Docker des dels teus tests. Base de dades real, dades reals, sense 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 és el teu amic aquí. Registra una funció que s’executa en acabar el test (o subtest), independentment de si passa o falla. És l’equivalent a defer però per al cicle de vida del test. Usa’l per netejar contenidors, tancar connexions, esborrar fitxers temporals.
El patró que recomano per a una API backend:
- Repositoris: Tests d’integració amb testcontainers contra base de dades real.
- Serveis: Tests unitaris amb mocks del repositori.
- Handlers: Tests unitaris amb
httptesti mocks del servei. - End-to-end: Un o dos tests que aixequen tot el stack (opcional però recomanable).
Test coverage: go test -cover
Go té cobertura de tests integrada. No necessites instal·lar res:
# Mostra percentatge de cobertura
go test -cover ./...
# Genera perfil de cobertura
go test -coverprofile=coverage.out ./...
# Visualitza al navegador
go tool cover -html=coverage.outLa visualització HTML és molt útil: et mostra línia per línia què està cobert (verd) i què no (vermell). És la forma més ràpida d’identificar branques no testejades.
# Cobertura per funció
go tool cover -func=coverage.outcalc/math.go:3: Add 100.0%
calc/math.go:7: Divide 75.0%
total: (statements) 87.5%Ara bé, un tema controvertit: el percentatge de cobertura no és una mètrica de qualitat. He vist projectes amb 95% de cobertura on els tests no verificaven res (només executaven el codi sense comprovar resultats). I projectes amb 60% de cobertura on cada test era sòlid.
El meu enfocament pragmàtic:
- Apunta a cobrir els camins crítics. Els happy paths i els errors que un usuari real pot provocar.
- No persegueixis el 100%. L’esforç per passar del 85% al 100% rarament compensa.
- Usa la cobertura com a eina exploratòria, no com a gate en CI. Veure què està en vermell t’ajuda a decidir què testejar, però un número no hauria de bloquejar un merge.
Benchmarks amb testing.B
Go té benchmarks integrats en el mateix paquet testing. Si necessites mesurar rendiment, no necessites eines externes.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}Execució:
go test -bench=. -benchmem ./...BenchmarkAdd-8 1000000000 0.2900 ns/op 0 B/op 0 allocs/opb.N és el nombre d’iteracions que Go decideix executar per obtenir una mesura estable. No el fixes tu. -benchmem afegeix informació d’allocations, que sol ser més útil que el temps brut per optimitzar codi Go.
Un benchmark més realista per a 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 temps del setup. Usa’l sempre que tinguis inicialització abans del bucle de benchmark.
Els benchmarks són útils per comparar implementacions, detectar regressions de rendiment i entendre el cost d’allocations. Però no els escriguis per a tot. Si el teu endpoint triga 200ms perquè espera a la base de dades, un benchmark que demostri que la teva serialització JSON triga 2 microsegons no aporta informació rellevant.
Errors comuns que destrossen els teus tests
Després d’anys escrivint i revisant tests en Go, aquests són els patrons que he vist causar més dolor:
Testejar detalls d’implementació
// MALAMENT: test acoblat a la implementació 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)
}
}Aquest test es trenca si afegeixes caching, si canvies d’un Get a un BatchGet, o si simplement refactoritzes. Testeja el resultat, no el camí.
// BÉ: testeja el comportament 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
Quan mockejeu tres capes d’abstracció per testejar una funció que suma dos números, alguna cosa ha anat molt malament. Si el teu test necessita configurar 15 mocks per funcionar, no és un test unitari: és una demostració que el teu codi té massa dependències.
Regla simple: si el mock és més complex que el codi real, usa el codi real.
Tests que ignoren errors
// MALAMENT: si Create retorna error, el test passa igualment
func TestCreateTask(t *testing.T) {
task, _ := svc.Create(ctx, Task{Title: \"Test\"})
if task.Title != \"Test\" {
t.Error(\"wrong title\")
}
}Si Create retorna un error i un Task buit, task.Title és \"\", el test falla, però per la raó equivocada. Sempre comprova errors als tests.
Tests que depenen de l’ordre
// MALAMENT: si TestA no s'executa abans, 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 s'ha executat primer
}Cada test hauria de funcionar de forma aïllada. Si necessites dades, crea-les dins del test o en un setup compartit amb TestMain.
Una estratègia de testing pragmàtica per a APIs backend
Després de tot l’anterior, aquesta és l’estratègia que uso i recomano per a APIs backend en Go. No és l’única possible, però ha funcionat consistentment en projectes reals.
La piràmide que funciona
Base: tests unitaris de serveis i lògica de negoci. Ràpids, sense dependències externes, amb mocks de repositoris. Aquí és on hi ha la majoria dels teus tests. Table-driven tests per cobrir variants. Verifiques regles de negoci, validacions, transformacions de dades.
Mig: tests unitaris de handlers HTTP. Amb
httptest.NewRecorderi mocks de serveis. Verifiques status codes, headers, format de resposta, gestió d’errors HTTP. No verifiques lògica de negoci aquí.Mig-alt: tests d’integració de repositoris. Amb testcontainers o una base de dades de test. Verifiques que les teves queries SQL funcionen, que els tipus es mapegen correctament, que les constraints es respecten.
Cim: un o dos tests E2E que aixequen l’API completa i fan peticions reals. Smoke tests que verifiquen que tot encaixa.
A la pràctica
projecte/
├── 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 e2eEl teu 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è testejar i què no
Testeja:
- Regles de negoci i validacions.
- Gestió d’errors (happy path I sad paths).
- Serialització/deserialització si és crítica.
- Queries SQL complexes (integració).
- Middleware que afecta seguretat (autenticació, autorització).
No testegis:
- Getters i setters trivials.
- Codi generat.
- Wrappers d’una línia sobre la llibreria estàndard.
- Que Go funcioni correctament (no testegis que
json.Marshalserialitza JSON).
La pregunta clau
Abans d’escriure un test, pregunta’t: “Si aquest test falla, sabré què s’ha trencat i com arreglar-ho?”
Si la resposta és sí, és un bon test. Si la resposta és “sabré que alguna cosa ha canviat, però no què ni per què”, el test està acoblat a detalls d’implementació i et donarà més feina de la que t’estalvia.
Go et dona eines simples. testing.T, httptest, interfícies, build tags. No necessites més per testejar una API backend de forma sòlida. La dificultat no està en les eines, sinó en decidir què mereix un test i què no. I això, com gairebé tot en enginyeria de programari, és una qüestió de criteri, no de framework.


