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:
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:
package calc_test
import (
"testing"
"github.com/yourorg/calc"
)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.
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/
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:
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:
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.
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.
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/opAggiungi -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.outI 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.
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.FatalPiu 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
Non usare i table-driven test
t.Run mantengono i test case uniformi, rendono facile identificare i fallimenti per nome e rendono banale aggiungere nuovi casi.Mockare tutto
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
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.