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.

Cover for Testing en Go per a APIs backend: unitaris, integració i mocks sense tornar-se boig

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:

  1. Està en un fitxer que acaba en _test.go.
  2. El seu nom comença per Test.
  3. 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.001s

Sense output addicional si tot passa. Si alguna cosa falla:

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

Hi ha tres coses que val la pena notar:

  • No hi ha asserts. Go no té assertEquals ni assertThat a la llibreria estàndard. Uses if i crides a t.Errorf o t.Fatalf. Això sembla primitiu, però té un avantatge: els missatges d’error són teus i diuen exactament el que vols que diguin.
  • t.Errorf no atura el test. Si vols que el test pari immediatament en fallar, usa t.Fatalf. Errorf registra el fallo i continua executant. Això és útil quan vols veure tots els fallos d’un cop.
  • El fitxer _test.go es 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_test

Usar 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_email

Això é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.Request sense obrir cap connexió. Pots configurar headers, body, query params, tot.
  • httptest.NewRecorder: implementa http.ResponseWriter i 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 assert i require de testify quan el boilerplate de if/t.Errorf et faci anar lent.
  • Evita suite de testify. Intenta replicar el setup/teardown basat en classes de xUnit i va contra la filosofia de Go. Un TestMain o 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:

  1. Repositoris: Tests d’integració amb testcontainers contra base de dades real.
  2. Serveis: Tests unitaris amb mocks del repositori.
  3. Handlers: Tests unitaris amb httptest i mocks del servei.
  4. 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.out

La 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.out
calc/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/op

b.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

  1. 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.

  2. Mig: tests unitaris de handlers HTTP. Amb httptest.NewRecorder i mocks de serveis. Verifiques status codes, headers, format de resposta, gestió d’errors HTTP. No verifiques lògica de negoci aquí.

  3. 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.

  4. 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 e2e

El 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.html

Què 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.Marshal serialitza 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.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats