Salta al contenuto principale

Testing in Go: Dai Unit Test ai Mock e ai Benchmark

·9 minuti
Indice dei contenuti
La filosofia di testing di Go e stdlib-first e table-driven. Raramente hai bisogno di un framework esterno. Il package testing, combinato con le interfacce per la dependency injection e httptest per HTTP, copre quasi tutto cio che incontrerai nei codebase di produzione.

Molti sviluppatori Go che vengono da Python o Java usano testify o gomock prima che ne abbiano effettivo bisogno. La libreria standard di Go e notevolmente completa per il testing. Questo post illustra i pattern che gli ingegneri Go esperti usano davvero: table-driven test con subtest, mock basati su interfacce, testing degli HTTP handler, benchmark e analisi della coverage.

Struttura del package
#

Inizia con un package concreto cosi gli esempi compilano. Ecco un piccolo package calc:

calc/calc.go
package calc

// Sum returns the sum of a and b.
func Sum(a, b int) int {
    return a + b
}

// Divide returns a/b. It returns an error if b is zero.
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

E il corrispondente file di test:

calc/calc_test.go
package calc_test

import (
    "testing"
    "github.com/yourorg/calc"
)
Nota

Usare package calc_test (il package di test esterno) invece di package calc ti forza a testare solo l’API esportata. Questo e lo stile idiomatico di Go e impedisce di fare affidamento accidentale sugli elementi interni non esportati.

Table-driven test con t.Run
#

I table-driven test sono il pattern standard di Go. Invece di una funzione di test per ogni input, definisci una slice di test case e la scorra. Ogni caso viene eseguito come subtest con nome tramite t.Run, il che significa che i fallimenti vengono riportati con un nome chiaro e puoi eseguire un singolo subtest con -run.

calc/calc_test.go
func TestSum(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"both positive", 2, 3, 5},
        {"negative plus positive", -1, 4, 3},
        {"both negative", -2, -3, -5},
        {"zero identity", 0, 7, 7},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := calc.Sum(tc.a, tc.b)
            if got != tc.expected {
                t.Errorf("Sum(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal division", 10, 2, 5, false},
        {"decimal result", 7, 2, 3.5, false},
        {"divide by zero", 5, 0, 0, true},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := calc.Divide(tc.a, tc.b)
            if tc.expectErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tc.expected {
                t.Errorf("Divide(%v, %v) = %v; want %v", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

Per eseguire un subtest specifico: go test -run TestDivide/divide_by_zero ./calc/

Suggerimento

Usa t.Fatal (non t.Error) quando un errore rende privo di senso il resto del subtest. t.Error marca il test come fallito ma continua l’esecuzione. t.Fatal ferma immediatamente il subtest.

Interfacce e mock
#

La tecnica di testing piu importante in Go e definire le dipendenze come interfacce e iniettare fake. Nessuna generazione di codice richiesta.

Definisci l’interfaccia nel consumer, non nel producer:

store/service.go
package store

import "context"

// Store is the interface our service depends on.
// It lives in the consumer package, not the database package.
type Store interface {
    GetUser(ctx context.Context, id int64) (*User, error)
    CreateUser(ctx context.Context, u *User) error
}

type UserService struct {
    store Store
}

func NewUserService(s Store) *UserService {
    return &UserService{store: s}
}

func (svc *UserService) Register(ctx context.Context, name, email string) (*User, error) {
    u := &User{Name: name, Email: email}
    if err := svc.store.CreateUser(ctx, u); err != nil {
        return nil, fmt.Errorf("creating user: %w", err)
    }
    return u, nil
}

Ora scrivi un fake nel file di test:

store/service_test.go
package store_test

import (
    "context"
    "testing"
    "github.com/yourorg/store"
)

// FakeStore implements store.Store for testing.
type FakeStore struct {
    users  map[int64]*store.User
    nextID int64
    // CreateErr lets tests inject a specific error.
    CreateErr error
}

func newFakeStore() *FakeStore {
    return &FakeStore{users: make(map[int64]*store.User)}
}

func (f *FakeStore) GetUser(_ context.Context, id int64) (*store.User, error) {
    u, ok := f.users[id]
    if !ok {
        return nil, store.ErrNotFound
    }
    return u, nil
}

func (f *FakeStore) CreateUser(_ context.Context, u *store.User) error {
    if f.CreateErr != nil {
        return f.CreateErr
    }
    f.nextID++
    u.ID = f.nextID
    f.users[f.nextID] = u
    return nil
}

func TestUserService_Register(t *testing.T) {
    t.Run("success", func(t *testing.T) {
        fake := newFakeStore()
        svc := store.NewUserService(fake)

        user, err := svc.Register(context.Background(), "Alice", "alice@example.com")
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("expected name Alice, got %s", user.Name)
        }
        if user.ID == 0 {
            t.Error("expected non-zero ID after creation")
        }
    })

    t.Run("store error propagates", func(t *testing.T) {
        fake := newFakeStore()
        fake.CreateErr = errors.New("db unavailable")
        svc := store.NewUserService(fake)

        _, err := svc.Register(context.Background(), "Bob", "bob@example.com")
        if err == nil {
            t.Fatal("expected error, got nil")
        }
    })
}

Questo approccio non richiede dipendenze esterne e produce test veloci e deterministici. Il fake memorizza lo stato in una mappa, che e sufficiente per la stragrande maggioranza degli scenari di unit test.

Testing degli HTTP handler con httptest
#

Il package net/http/httptest di Go ti permette di testare gli handler senza fare il bind a una porta.

api/handler_test.go
package api_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/yourorg/api"
)

func TestGetUserHandler(t *testing.T) {
    tests := []struct {
        name           string
        userID         string
        storeResp      *api.User
        storeErr       error
        expectedStatus int
    }{
        {
            name:           "found",
            userID:         "42",
            storeResp:      &api.User{ID: 42, Name: "Alice"},
            expectedStatus: http.StatusOK,
        },
        {
            name:           "not found",
            userID:         "99",
            storeErr:       api.ErrNotFound,
            expectedStatus: http.StatusNotFound,
        },
        {
            name:           "invalid id",
            userID:         "abc",
            expectedStatus: http.StatusBadRequest,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            fake := &fakeUserStore{user: tc.storeResp, err: tc.storeErr}
            handler := api.NewUserHandler(fake)

            req := httptest.NewRequest(http.MethodGet, "/users/"+tc.userID, nil)
            rec := httptest.NewRecorder()

            handler.ServeHTTP(rec, req)

            if rec.Code != tc.expectedStatus {
                t.Errorf("status = %d; want %d", rec.Code, tc.expectedStatus)
            }

            if tc.expectedStatus == http.StatusOK {
                var got api.User
                if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
                    t.Fatalf("decoding response: %v", err)
                }
                if got.ID != tc.storeResp.ID {
                    t.Errorf("user ID = %d; want %d", got.ID, tc.storeResp.ID)
                }
            }
        })
    }
}

httptest.NewRecorder() cattura la risposta senza una vera chiamata di rete. httptest.NewRequest costruisce un valido *http.Request. Il pattern si compone naturalmente con i table-driven test.

Funzioni di benchmark
#

I benchmark vivono nei file _test.go con la firma func BenchmarkXxx(b *testing.B). Il campo b.N viene regolato automaticamente dal framework fino a quando il benchmark non viene eseguito abbastanza a lungo da essere misurato in modo affidabile.

calc/calc_bench_test.go
package calc_test

import (
    "testing"
    "github.com/yourorg/calc"
)

func BenchmarkSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        calc.Sum(100, 200)
    }
}

// BenchmarkDivide_repeated simula un percorso memoizzato
// misurando chiamate ripetute con gli stessi input.
func BenchmarkDivide_repeated(b *testing.B) {
    for i := 0; i < b.N; i++ {
        calc.Divide(1000.0, 3.0) //nolint:errcheck
    }
}

Esegui i benchmark con: go test -bench=. -benchmem ./calc/

Output di esempio:

BenchmarkSum-10                1000000000    0.31 ns/op    0 B/op    0 allocs/op
BenchmarkDivide_repeated-10    500000000     2.01 ns/op    0 B/op    0 allocs/op
Suggerimento

Aggiungi -benchmem a ogni esecuzione di benchmark. Le allocazioni spesso contano piu del tempo CPU grezzo per il codice latency-sensitive. Una funzione che mostra 0 allocs/op di solito puo essere inline dal compilatore.

Usa i benchmark per confrontare due implementazioni. Scrivi entrambe, metti a confronto entrambe, scegli quella piu veloce con evidenza.

Coverage
#

# Genera il profilo di coverage
go test -coverprofile=coverage.out ./...

# Visualizza la coverage per funzione nel terminale
go tool cover -func=coverage.out

# Apri un report HTML annotato nel browser
go tool cover -html=coverage.out

I numeri di coverage richiedono contesto. L'80% su un package di elaborazione pagamenti e preoccupante. L'80% su un parser di flag CLI potrebbe essere accettabile. Concentra l’effort di coverage sul codice con alto rischio di business o logica di branching complessa.

Avviso

La coverage misura quali righe sono state eseguite durante i test, non se i test verificano effettivamente il comportamento corretto. Un test che chiama una funzione ma non fa asserzioni gonfia il tuo numero di coverage senza fornire alcuna rete di sicurezza.

Testify vs stdlib
#

if got != want {
    t.Errorf("Sum() = %d; want %d", got, want)
}

if err == nil {
    t.Fatal("expected error, got nil")
}

if !reflect.DeepEqual(got, want) {
    t.Errorf("got %+v; want %+v", got, want)
}

Nessun overhead di import. I messaggi di errore sono espliciti. Funziona ovunque.

assert.Equal(t, want, got)
assert.NoError(t, err)
assert.ErrorIs(t, err, ErrNotFound)
require.NotNil(t, user)  // ferma il test al fallimento come t.Fatal

Piu pulito per controlli di uguaglianza profonda, asserzioni su catene di errori e test suite. Il package require e un drop-in per la semantica di t.Fatal.

La regola: inizia con stdlib. Aggiungi testify quando il codice delle asserzioni comincia ad annegare la logica del test.

Errori comuni
#

Testare i dettagli di implementazione invece del comportamento
I test che fanno asserzioni sullo stato privato o sull’esatta sequenza di chiamate interne sono fragili. Si rompono quando fai refactoring anche se il comportamento esterno e invariato. Testa cio che una funzione restituisce e gli effetti che produce, non come fa il lavoro internamente.
Non usare i table-driven test
Scrivere una funzione di test per ogni input porta a errori di copy-paste, naming inconsistente e test difficili da estendere. I table-driven test con t.Run mantengono i test case uniformi, rendono facile identificare i fallimenti per nome e rendono banale aggiungere nuovi casi.
Mockare tutto
Mockare uno strings.Builder o una semplice funzione di utilita aggiunge rumore senza benefici. Mocka solo le cose che hanno effetti esterni (database, HTTP, filesystem, tempo) o che sono lente. Se una dipendenza e veloce, pura e senza effetti collaterali, usa semplicemente l’implementazione reale nei test.
Usare reflect.DeepEqual per confronti primitivi
reflect.DeepEqual(5, 5) funziona ma e eccessivamente pesante per confronti di interi o stringhe. Usa == direttamente. Riserva reflect.DeepEqual o cmp.Diff per struct e slice dove conta il confronto campo per campo.
Stato globale condiviso nei test
I test che condividono stato globale mutabile sono order-dependent. Passano in isolamento e falliscono nelle suite. Ogni test dovrebbe configurare il proprio stato nella funzione di test o in t.Cleanup, e smontarlo dopo. Usa t.Parallel() per far emergere le race condition prima.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architetture cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati