Test a tabella parallela in Go

Accelerare i test Go con l'esecuzione parallela

Indice

Test-driven tests basati su tabelle sono l’approccio idiomatico in Go per testare efficacemente diversi scenari. Quando combinati con l’esecuzione parallela utilizzando t.Parallel(), è possibile ridurre drasticamente il tempo di esecuzione del suite di test, specialmente per operazioni I/O-bound.

Tuttavia, l’esecuzione parallela dei test introduce sfide uniche intorno alle condizioni di competizione e all’isolamento dei test che richiedono attenzione particolare.

Test-driven tests paralleli in Go - golang condizioni di competizione

Comprendere l’esecuzione parallela dei test

Il pacchetto di test di Go fornisce un supporto integrato per l’esecuzione parallela dei test attraverso il metodo t.Parallel(). Quando un test chiama t.Parallel(), segnala al runner dei test che questo test può essere eseguito in modo sicuro in parallelo con altri test paralleli. Questo è particolarmente potente quando combinato con i test basati su tabelle, dove si hanno molti casi di test indipendenti che possono essere eseguiti contemporaneamente.

La parallelità predefinita è controllata da GOMAXPROCS, che di solito è uguale al numero di core CPU del tuo computer. Puoi regolarla con il flag -parallel: go test -parallel 4 limita i test paralleli a 4, indipendentemente dal numero di CPU. Questo è utile per controllare l’utilizzo delle risorse o quando i test hanno requisiti specifici di concorrenza.

Per gli sviluppatori nuovi ai test in Go, comprendere i fondamenti è cruciale. La nostra guida su migliori pratiche per i test unitari in Go copre i test basati su tabelle, sottotesti e i fondamenti del pacchetto testing che formano la base per l’esecuzione parallela.

Modello di base per i test basati su tabelle in parallelo

Ecco il modello corretto per i test basati su tabelle in parallelo:

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 {
        tt := tt // Cattura la variabile del ciclo
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Abilita l'esecuzione parallela
            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)
            }
        })
    }
}

La riga critica è tt := tt prima di t.Run(). Questo cattura il valore corrente della variabile del ciclo, assicurando che ogni sottotest parallelo operi su una copia propria dei dati del caso di test.

Il problema della cattura della variabile del ciclo

Questo è uno dei problemi più comuni quando si utilizza t.Parallel() con i test basati su tabelle. In Go, la variabile del ciclo tt è condivisa in tutte le iterazioni. Quando i sottotesti vengono eseguiti in parallelo, potrebbero tutti riferirsi alla stessa variabile tt, che viene sovrascritta man mano che il ciclo continua. Questo porta a condizioni di competizione e a fallimenti dei test imprevedibili.

Erroneo (condizione di competizione):

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Tutti i sottotesti potrebbero vedere lo stesso valore di tt!
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

Corretto (variabile catturata):

for _, tt := range tests {
    tt := tt // Cattura la variabile del ciclo
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Ogni sottotest ha la propria copia di tt
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

L’assegnazione tt := tt crea una nuova variabile limitata all’iterazione del ciclo, assicurando che ogni routine concorrente abbia la propria copia dei dati del caso di test.

Garantire l’indipendenza dei test

Per funzionare correttamente, ogni test deve essere completamente indipendente. Non dovrebbero:

  • Condividere stato globale o variabili
  • Modificare risorse condivise senza sincronizzazione
  • Dipendere dall’ordine di esecuzione
  • Accedere agli stessi file, database o risorse di rete senza coordinamento

Esempio di test paralleli indipendenti:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"email valida", "user@example.com", false},
        {"formato non valido", "not-an-email", true},
        {"dominio mancante", "user@", true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", 
                    tt.email, err, tt.wantErr)
            }
        })
    }
}

Ogni caso di test opera sui propri dati di input senza stato condiviso, rendendolo sicuro per l’esecuzione parallela.

Rilevamento delle condizioni di competizione

Go fornisce un potente rilevatore di condizioni di competizione per catturare le corse di dati nei test paralleli. Esegui sempre i tuoi test paralleli con il flag -race durante lo sviluppo:

go test -race ./...

Il rilevatore di condizioni di competizione segnalerà qualsiasi accesso concorrente alla memoria condivisa senza sincronizzazione appropriata. Questo è essenziale per catturare bug sottili che potrebbero apparire solo sotto specifiche condizioni di timing.

Esempio di condizione di competizione:

var counter int // Stato condiviso - PERICOLOSO!

func TestIncrement(t *testing.T) {
    tests := []struct {
        name string
        want int
    }{
        {"test1", 1},
        {"test2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            counter++ // CONDIZIONE DI COMPETIZIONE!
            if counter != tt.want {
                t.Errorf("counter = %d, want %d", counter, tt.want)
            }
        })
    }
}

Eseguendo questo con -race verrà rilevata la modifica concorrente di counter. La soluzione è rendere ogni test indipendente utilizzando variabili locali invece di stato condiviso.

Vantaggi di Prestazione

L’esecuzione parallela può ridurre drasticamente il tempo di esecuzione del suite di test. La velocità dipende da:

  • Numero di core CPU: Più core permettono a più test di essere eseguiti contemporaneamente
  • Caratteristiche dei test: I test I/O-bound beneficiano di più dei test CPU-bound
  • Numero di test: I suite di test più grandi vedono maggiori risparmi di tempo assoluti

Misurare le prestazioni:

# Esecuzione sequenziale
go test -parallel 1 ./...

# Esecuzione parallela (predefinita)
go test ./...

# Parallelismo personalizzato
go test -parallel 8 ./...

Per i suite di test con molte operazioni I/O (query al database, richieste HTTP, operazioni sui file), è possibile ottenere un aumento di velocità del 2-4x sui sistemi moderni a multi-core. I test CPU-bound potrebbero vedere meno vantaggi a causa della competizione per le risorse CPU.

Controllo della Parallelità

Hai diverse opzioni per controllare l’esecuzione parallela dei test:

1. Limitare il numero massimo di test paralleli:

go test -parallel 4 ./...

2. Impostare GOMAXPROCS:

GOMAXPROCS=2 go test ./...

3. Esecuzione parallela selettiva:

Segnala solo i test specifici con t.Parallel(). I test senza questa chiamata vengono eseguiti in modo sequenziale, che è utile quando alcuni test devono essere eseguiti in ordine o condividono risorse.

4. Esecuzione parallela condizionale:

func TestExpensive(t *testing.T) {
    if testing.Short() {
        t.Skip("saltando il test costoso in modalità breve")
    }
    t.Parallel()
    // Logica del test costoso
}

Pattern Comuni e Migliori Pratiche

Pattern 1: Configurazione prima dell’esecuzione parallela

Se necessiti di una configurazione condivisa tra tutti i casi di test, fallo prima del ciclo:

func TestWithSetup(t *testing.T) {
    // La configurazione viene eseguita una volta, prima dell'esecuzione parallela
    db := setupTestDatabase(t)
    defer db.Close()

    tests := []struct {
        name string
        id   int
    }{
        {"utente1", 1},
        {"utente2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Ogni test utilizza db in modo indipendente
            user := db.GetUser(tt.id)
            // Logica del test...
        })
    }
}

Pattern 2: Configurazione per test

Per i test che necessitano di configurazione isolata, fallo all’interno di ogni sottotest:

func TestWithPerTestSetup(t *testing.T) {
    tests := []struct {
        name string
        data string
    }{
        {"test1", "dati1"},
        {"test2", "dati2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Ogni test ottiene la propria configurazione
            tempFile := createTempFile(t, tt.data)
            defer os.Remove(tempFile)
            // Logica del test...
        })
    }
}

Pattern 3: Misto sequenziale e parallelo

Puoi mescolare test sequenziali e paralleli nello stesso file:

func TestSequential(t *testing.T) {
    // Nessun t.Parallel() - eseguito in modo sequenziale
    // Buono per i test che devono essere eseguiti in ordine
}

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"test1"}, {"test2"}}
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Questi vengono eseguiti in parallelo
        })
    }
}

Quando NON utilizzare l’esecuzione parallela

L’esecuzione parallela non è sempre appropriata. Evitala quando:

  1. I test condividono stato: Variabili globali, singleton o risorse condivise
  2. I test modificano file condivisi: File temporanei, database di test o file di configurazione
  3. I test dipendono dall’ordine di esecuzione: Alcuni test devono essere eseguiti prima di altri
  4. I test sono già veloci: L’overhead della parallelizzazione potrebbe superare i benefici
  5. Vincoli di risorse: I test consumano troppa memoria o CPU quando parallelizzati

Per i test relativi al database, considera l’utilizzo di rollbacks delle transazioni o database di test separati per ogni test. La nostra guida su pattern di database multi-tenant in Go copre strategie di isolamento che funzionano bene con i test paralleli.

Avanzato: Test del codice concorrente

Quando si testa il codice concorrente stesso (non solo l’esecuzione parallela dei test), sono necessarie tecniche aggiuntive:

func TestConcurrentOperation(t *testing.T) {
    tests := []struct {
        name      string
        goroutines int
    }{
        {"2 goroutines", 2},
        {"10 goroutines", 10},
        {"100 goroutines", 100},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            var wg sync.WaitGroup
            results := make(chan int, tt.goroutines)
            
            for i := 0; i < tt.goroutines; i++ {
                wg.Add(1)
                go func() {
                    defer wg.Done()
                    results <- performOperation()
                }()
            }
            
            wg.Wait()
            close(results)
            
            // Verifica i risultati
            count := 0
            for range results {
                count++
            }
            if count != tt.goroutines {
                t.Errorf("atteso %d risultati, ottenuti %d", tt.goroutines, count)
            }
        })
    }
}

Esegui sempre tali test con -race per rilevare le corse di dati nel codice sottoposto a test.

Integrazione con CI/CD

I test paralleli si integrano in modo fluido con le pipeline CI/CD. La maggior parte dei sistemi CI fornisce più core CPU, rendendo l’esecuzione parallela molto vantaggiosa:

# Esempio GitHub Actions
- name: Esegui test
  run: |
    go test -race -coverprofile=coverage.out -parallel 4 ./...
    go tool cover -html=coverage.out -o coverage.html    

Il flag -race è particolarmente importante in CI per catturare bug di concorrenza che potrebbero non apparire nello sviluppo locale.

Debugging dei fallimenti dei test paralleli

Quando i test paralleli falliscono in modo intermittente, il debugging può essere difficile:

  1. Esegui con -race: Identifica le corse di dati
  2. Riduci la parallelità: go test -parallel 1 per vedere se i fallimenti scompaiono
  3. Esegui test specifici: go test -run TestName per isolare il problema
  4. Aggiungi il logging: Utilizza t.Log() per tracciare l’ordine di esecuzione
  5. Controlla lo stato condiviso: Cerca variabili globali, singleton o risorse condivise

Se i test passano in modo sequenziale ma falliscono in parallelo, probabilmente hai un problema di condizione di competizione o di stato condiviso.

Esempio pratico: Test degli handler HTTP

Ecco un esempio pratico di test degli handler HTTP in parallelo:

func TestHTTPHandlers(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        method     string
        path       string
        statusCode int
    }{
        {"GET utenti", "GET", "/utenti", 200},
        {"GET utente per ID", "GET", "/utenti/1", 200},
        {"POST utente", "POST", "/utenti", 201},
        {"DELETE utente", "DELETE", "/utenti/1", 204},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            router.ServeHTTP(w, req)
            
            if w.Code != tt.statusCode {
                t.Errorf("atteso stato %d, ottenuto %d", tt.statusCode, w.Code)
            }
        })
    }
}

Ogni test utilizza httptest.NewRecorder(), che crea un registratore di risposta isolato, rendendo questi test sicuri per l’esecuzione parallela.

Conclusione

L’esecuzione parallela dei test basati su tabelle è una tecnica potente per ridurre il tempo di esecuzione del suite di test in Go. La chiave per il successo è comprendere il requisito di cattura della variabile del ciclo, garantire l’indipendenza dei test e utilizzare il rilevatore di condizioni di competizione per catturare i problemi di concorrenza fin da subito.

Ricorda:

  • Sempre catturare le variabili del ciclo: tt := tt prima di t.Parallel()
  • Garantire che i test siano indipendenti senza stato condiviso
  • Eseguire i test con -race durante lo sviluppo
  • Controllare la parallelità con il flag -parallel quando necessario
  • Evitare l’esecuzione parallela per i test che condividono risorse

Seguendo queste pratiche, puoi in modo sicuro sfruttare l’esecuzione parallela per accelerare i tuoi suite di test mantenendo l’affidabilità. Per ulteriori pattern di test in Go, consulta la nostra guida completa su test unitari in Go, che copre test basati su tabelle, mock e altre tecniche essenziali di test.

Quando si costruiscono applicazioni Go più grandi, queste pratiche di test si applicano in diversi domini. Ad esempio, quando si costruiscono applicazioni CLI con Cobra & Viper, userai pattern simili per i test degli handler dei comandi e del parsing della configurazione.

Risorse esterne