Unit Testing in Go: Struttura e Migliori Pratiche

Test con Go da basi a pattern avanzati

Indice

Il pacchetto di test integrato in Go fornisce un potente framework minimalista per scrivere test unitari senza dipendenze esterne. Ecco i fondamenti del testing, la struttura del progetto e i pattern avanzati per costruire applicazioni Go affidabili.

Il testing unitario in Go è fantastico

Perché il testing è importante in Go

La filosofia di Go enfatizza semplicità e affidabilità. La libreria standard include il pacchetto testing, rendendo il testing unitario un cittadino di prima classe nell’ecosistema Go. Il codice Go ben testato migliora la manutenibilità, cattura i bug precocemente e fornisce documentazione attraverso esempi. Se sei nuovo a Go, consulta il nostro Go Cheat Sheet per un riferimento rapido sui fondamenti del linguaggio.

Vantaggi principali del testing in Go:

  • Supporto integrato: Non sono necessari framework esterni
  • Esecuzione veloce: Esecuzione concorrente dei test per default
  • Sintassi semplice: Minima quantità di codice boilerplate
  • Strumenti ricchi: Report di copertura, benchmark e profilatura
  • Amichevole per CI/CD: Integrazione facile con pipeline automatizzate

Struttura del progetto per i test in Go

I test in Go vivono accanto al codice produttivo con una chiara convenzione di nomi:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Convenzioni principali:

  • I file di test terminano con _test.go
  • I test sono nello stesso pacchetto del codice (o utilizzano il suffisso _test per il testing nero)
  • Ogni file sorgente può avere un file di test corrispondente

Approcci al testing del pacchetto

Testing bianco (stesso pacchetto):

package calculator

import "testing"
// Può accedere alle funzioni e variabili non esportate

Testing nero (pacchetto esterno):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Può accedere solo alle funzioni esportate (consigliato per le API pubbliche)

Struttura di base dei test

Ogni funzione di test segue questo modello:

package calculator

import "testing"

// La funzione di test deve iniziare con "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Metodi di testing.T:

  • t.Error() / t.Errorf(): Contrassegna il test come fallito ma continua
  • t.Fatal() / t.Fatalf(): Contrassegna il test come fallito e ferma immediatamente
  • t.Log() / t.Logf(): Output di log (visualizzato solo con il flag -v)
  • t.Skip() / t.Skipf(): Salta il test
  • t.Parallel(): Esegui il test in parallelo con altri test paralleli

Test a tabelle: Il modo Go

I test a tabelle sono l’approccio idiomatico in Go per testare diversi scenari. Con Go generics, puoi anche creare helper di test type-safe che funzionano su diversi tipi di dati:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addizione", 2, 3, "+", 5, false},
        {"sottrazione", 5, 3, "-", 2, false},
        {"moltiplicazione", 4, 3, "*", 12, false},
        {"divisione", 10, 2, "/", 5, false},
        {"divisione per zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Vantaggi:

  • Una singola funzione di test per diversi scenari
  • Facile aggiunta di nuovi casi di test
  • Documentazione chiara del comportamento previsto
  • Organizzazione e manutenibilità dei test migliori

Esecuzione dei test

Comandi di base

# Esegui i test nella directory corrente
go test

# Esegui i test con output dettagliato
go test -v

# Esegui i test in tutte le sottodirectory
go test ./...

# Esegui un test specifico
go test -run TestAdd

# Esegui test che corrispondono a un pattern
go test -run TestCalculate/addizione

# Esegui i test in parallelo (predefinito è GOMAXPROCS)
go test -parallel 4

# Esegui i test con timeout
go test -timeout 30s

Copertura dei test

# Esegui i test con copertura
go test -cover

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

# Visualizza la copertura nel browser
go tool cover -html=coverage.out

# Mostra la copertura per funzione
go tool cover -func=coverage.out

# Imposta la modalità di copertura (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Flag utili

  • -short: Esegui i test contrassegnati con if testing.Short()
  • -race: Abilita il rilevatore di corse (trova problemi di accesso concorrente)
  • -cpu: Specifica i valori di GOMAXPROCS
  • -count n: Esegui ciascun test n volte
  • -failfast: Ferma al primo fallimento del test

Helper per i test e setup/teardown

Funzioni helper

Contrassegna le funzioni helper con t.Helper() per migliorare la segnalazione degli errori:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Questa riga è segnalata come chiamante
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // La riga dell'errore punta qui
}

Setup e teardown

func TestMain(m *testing.M) {
    // Codice di setup
    setup()
    
    // Esegui i test
    code := m.Run()
    
    // Codice di teardown
    teardown()
    
    os.Exit(code)
}

Fixtures per i test

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Codice del test
}

Mocking e iniezione di dipendenze

Mocking basato su interfacce

Quando si testa il codice che interagisce con database, l’uso di interfacce rende facile la creazione di implementazioni mock. Se stai lavorando con PostgreSQL in Go, consulta il nostro confronto tra ORMs Go per scegliere la libreria database giusta con buona testabilità.

// Codice produttivo
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Codice di test
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("utente non trovato")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("errore inaspettato: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Librerie di testing popolari

Testify

La libreria di testing più popolare in Go per affermazioni e mock:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "dovrebbero essere uguali")
    assert.NotNil(t, result)
}

// Esempio di mock
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Altri strumenti

  • gomock: Framework di mocking di Google con generazione del codice
  • httptest: Libreria standard per testare i gestori HTTP
  • testcontainers-go: Testing di integrazione con container Docker
  • ginkgo/gomega: Framework di testing BDD-style

Quando si testano integrazioni con servizi esterni come modelli AI, è necessario mockare o stubbare quelle dipendenze. Ad esempio, se stai utilizzando Ollama in Go, considera la creazione di wrapper di interfacce per rendere il tuo codice più testabile.

Test di benchmark

Go include un supporto integrato per i benchmark:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Esegui i benchmark
// go test -bench=. -benchmem

L’output mostra le iterazioni al secondo e le allocazioni di memoria.

Linee guida

  1. Scrivi test a tabelle: Utilizza il modello slice di struct per diversi casi di test
  2. Usa t.Run per i sottotest: Migliore organizzazione e possibilità di eseguire i sottotest in modo selettivo
  3. Testa prima le funzioni esportate: Concentrati sul comportamento dell’API pubblica
  4. Mantieni i test semplici: Ogni test dovrebbe verificare una sola cosa
  5. Usa nomi significativi per i test: Descrivi cosa viene testato e l’esito previsto
  6. Non testare i dettagli dell’implementazione: Testa il comportamento, non gli interni
  7. Usa interfacce per le dipendenze: Rende più facile il mocking
  8. Mirare a una buona copertura, ma qualità prima della quantità: La copertura al 100% non significa assenza di bug
  9. Esegui i test con il flag -race: Cattura i problemi di concorrenza precocemente
  10. Usa TestMain per il setup costoso: Evita di ripetere il setup in ogni test

Esempio: Suite di test completa

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("il nome non può essere vuoto")
    }
    if u.Email == "" {
        return errors.New("l'email non può essere vuota")
    }
    return nil
}

// File di test: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "utente valido",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "nome vuoto",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "il nome non può essere vuoto",
        },
        {
            name:    "email vuota",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "l'email non può essere vuota",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Conclusione

Il framework di testing in Go fornisce tutto ciò di cui hai bisogno per un testing unitario completo con un minimo di configurazione. Seguendo gli idiomi Go come i test a tabelle, utilizzando interfacce per il mocking e sfruttando gli strumenti integrati, puoi creare suite di test mantenibili e affidabili che crescono insieme al tuo codicebase.

Queste pratiche di testing si applicano a tutti i tipi di applicazioni Go, dalle web service alle applicazioni CLI costruite con Cobra & Viper. Il testing degli strumenti a riga di comando richiede pattern simili con un ulteriore focus sul testing di input/output e parsing delle opzioni.

Inizia con test semplici, aggiungi gradualmente la copertura e ricorda che il testing è un investimento nella qualità del codice e nella fiducia del team di sviluppo. L’accento della comunità Go sul testing rende più facile mantenere i progetti a lungo termine e collaborare efficacemente con i membri del team.