1. Introduzione al Testing Unitario

Il testing unitario si riferisce alla verifica e convalida dell'unità di test più piccola in un programma, come una funzione o un metodo nel linguaggio Go. Il testing unitario garantisce che il codice funzioni come previsto e consente ai programmatori di apportare modifiche al codice senza rompere accidentalmente la funzionalità esistente.

In un progetto in Golang, l'importanza del testing unitario è fuori discussione. In primo luogo, può migliorare la qualità del codice, dando ai programmatori maggiore fiducia nel fare modifiche al codice. In secondo luogo, il testing unitario può servire come documentazione per il codice, spiegandone il comportamento atteso. Inoltre, l'esecuzione automatica dei test unitari in un ambiente di integrazione continua può scoprire prontamente bug appena introdotti, migliorando così la stabilità del software.

2. Eseguire Test di Base Utilizzando il Pacchetto testing

La libreria standard del linguaggio Go include il pacchetto testing, che fornisce strumenti e funzionalità per scrivere ed eseguire i test.

2.1 Creazione del Primo Caso di Test

Per scrivere una funzione di test, è necessario creare un file con il suffisso _test.go. Ad esempio, se il file del codice sorgente è chiamato calculator.go, il file di test dovrebbe essere chiamato calculator_test.go.

Successivamente, è il momento di creare la funzione di test. Una funzione di test deve importare il pacchetto testing e seguire un certo formato. Ecco un semplice esempio:

// calculator_test.go
package calculator

import (
	"testing"
	"fmt"
)

// Test della funzione di aggiunta
func TestAdd(t *testing.T) {
	result := Add(1, 2)
	expected := 3

if result != expected {
		t.Errorf("Previsto %v, ma ottenuto %v", expected, result)
	}
}

In questo esempio, TestAdd è una funzione di test che testa un'immaginaria funzione Add. Se il risultato della funzione Add corrisponde al risultato atteso, il test passerà; in caso contrario, verrà chiamato t.Errorf per registrare le informazioni sul fallimento del test.

2.2 Comprensione delle Regole di Nomenclatura e della Firma delle Funzioni di Test

Le funzioni di test devono iniziare con Test, seguito da una stringa diversa da minuscole, e il loro unico parametro deve essere un puntatore a testing.T. Come mostrato nell'esempio, TestAdd segue le corrette regole di nomenclatura e firma.

2.3 Esecuzione dei Casi di Test

È possibile eseguire i casi di test utilizzando lo strumento della riga di comando. Per un caso di test specifico, eseguire il seguente comando:

go test -v // Esegue i test nella directory corrente e visualizza l'output dettagliato

Se si desidera eseguire un caso di test specifico, è possibile utilizzare il flag -run seguito da un'espressione regolare:

go test -v -run TestAdd // Esegue solo la funzione di test TestAdd

Il comando go test troverà automaticamente tutti i file _test.go ed eseguirà ogni funzione di test che soddisfi i criteri. Se tutti i test passano, verrà visualizzato un messaggio simile a PASS nella riga di comando; se un test fallisce, verrà visualizzato FAIL insieme al relativo messaggio di errore corrispondente.

3. Scrivere Casi di Test

3.1 Segnalare Errori Utilizzando t.Errorf e t.Fatalf

Nel linguaggio Go, il framework di testing fornisce vari metodi per segnalare gli errori. Le due funzioni più comunemente utilizzate sono Errorf e Fatalf, entrambe sono metodi dell'oggetto testing.T. Errorf viene utilizzata per segnalare gli errori nel test senza interrompere il caso di test corrente, mentre Fatalf interrompe immediatamente il test corrente dopo aver segnalato un errore. È importante scegliere il metodo appropriato in base ai requisiti del test.

Esempio di utilizzo di Errorf:

func TestAdd(t *testing.T) {
    got := Add(1, 2)
    want := 3
    if got != want {
        t.Errorf("Add(1, 2) = %d; previsto %d", got, want)
    }
}

Se si desidera interrompere immediatamente il test quando viene rilevato un errore, è possibile utilizzare Fatalf:

func TestSubtract(t *testing.T) {
    got := Subtract(5, 3)
    if got != 2 {
        t.Fatalf("Subtract(5, 3) = %d; previsto 2", got)
    }
}

In generale, se l'errore causerebbe l'esecuzione non corretta del codice successivo o il fallimento del test può essere confermato in anticipo, è consigliabile utilizzare Fatalf. Altrimenti, è consigliabile utilizzare Errorf per ottenere un risultato di test più completo.

3.2 Organizzazione dei sotto-test e esecuzione dei sotto-test

In Go, possiamo utilizzare t.Run per organizzare i sotto-test, il che ci aiuta a scrivere codice di test in modo più strutturato. I sotto-test possono avere il proprio Setup e Teardown e possono essere eseguiti singolarmente, offrendo grande flessibilità. Questo è particolarmente utile per condurre test complessi o test parametrizzati.

Esempio di utilizzo del sotto-test t.Run:

func TestMultiply(t *testing.T) {
    testcases := []struct {
        name           string
        a, b, expected int
    }{
        {"2x3", 2, 3, 6},
        {"-1x-1", -1, -1, 1},
        {"0x4", 0, 4, 0},
    }

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

Se vogliamo eseguire singolarmente il sotto-test chiamato "2x3", possiamo eseguire il seguente comando nella riga di comando:

go test -run TestMultiply/2x3

Si noti che i nomi dei sotto-test sono sensibili alle maiuscole e minuscole.

4. Lavoro preparatorio prima e dopo i test

4.1 Setup e Teardown

Durante i test, spesso è necessario preparare uno stato iniziale per i test (come la connessione al database, la creazione di file, ecc.), e allo stesso modo, è necessario effettuare del lavoro di pulizia dopo il completamento dei test. In Go, di solito eseguiamo Setup e Teardown direttamente nelle funzioni di test, e la funzione t.Cleanup ci fornisce la capacità di registrare le funzioni di richiamo per la pulizia.

Ecco un esempio semplice:

func TestDatabase(t *testing.T) {
    db, err := SetupDatabase()
    if err != nil {
        t.Fatalf("setup fallito: %v", err)
    }

    // Registra una funzione di richiamo per garantire che la connessione al database venga chiusa quando il test è terminato
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("impossibile chiudere il database: %v", err)
        }
    })

    // Esegui il test...
}

Nella funzione TestDatabase, chiamiamo innanzitutto la funzione SetupDatabase per impostare l'ambiente di test. Poi, utilizziamo t.Cleanup() per registrare una funzione che verrà chiamata dopo il completamento del test per eseguire il lavoro di pulizia, in questo esempio, si tratta di chiudere la connessione al database. In questo modo, possiamo garantire che le risorse vengano rilasciate correttamente, indipendentemente dal successo o dal fallimento del test.

5. Migliorare l'efficienza dei test

Migliorare l'efficienza dei test può aiutarci a iterare lo sviluppo più rapidamente, scoprire rapidamente i problemi e garantire la qualità del codice. Di seguito, parleremo della copertura del test, dei test basati su tabelle e dell'uso di mock per migliorare l'efficienza del test.

5.1 Copertura del test e strumenti correlati

Lo strumento go test fornisce una funzionalità di copertura dei test molto utile, che ci aiuta a capire quali parti del codice sono coperte dai casi di test, consentendo così di scoprire le aree del codice che non sono coperte dai casi di test.

Utilizzando il comando go test -cover, è possibile visualizzare la percentuale attuale di copertura dei test:

go test -cover

Se si desidera comprendere più dettagliatamente quali righe di codice sono state eseguite e quali no, è possibile utilizzare il parametro -coverprofile, che genera un file di dati di copertura. A questo punto, è possibile utilizzare il comando go tool cover per generare un rapporto dettagliato sulla copertura del test.

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Il comando precedente aprirà un report web che mostra in modo visuale quali righe di codice sono coperte dai test e quali no. Il verde rappresenta il codice testato, mentre il rosso rappresenta le righe di codice non coperte.

5.2 Utilizzo di Mock

Nel testing, ci troviamo spesso di fronte a scenari in cui è necessario simulare delle dipendenze esterne. I mock possono aiutarci a simulare queste dipendenze, eliminando la necessità di fare affidamento su servizi o risorse esterne specifici nell'ambiente di testing.

Nella community Go, esistono molteplici strumenti per i mock, come testify/mock e gomock. Solitamente questi strumenti forniscono una serie di API per creare e utilizzare oggetti mock.

Ecco un esempio di base dell'utilizzo di testify/mock. La prima cosa da fare è definire un'interfaccia e la relativa versione mock:

type DataService interface {
    FetchData() (int, error)
}

type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) FetchData() (int, error) {
    args := m.Called()
    return args.Int(0), args.Error(1)
}

Nel testing, possiamo utilizzare MockDataService per sostituire il servizio dati effettivo:

func TestSomething(t *testing.T) {
    mockDataSvc := new(MockDataService)
    mockDataSvc.On("FetchData").Return(42, nil) // Configurare il comportamento atteso

    result, err := mockDataSvc.FetchData() // Utilizzare l'oggetto mock
    assert.NoError(t, err)
    assert.Equal(t, 42, result)

    mockDataSvc.AssertExpectations(t) // Verificare se il comportamento atteso è avvenuto
}

Attraverso l'approccio sopra descritto, possiamo evitare di dipendere da servizi esterni o chiamate al database nel testing. Ciò può velocizzare l'esecuzione del test e rendere i nostri test più stabili e affidabili.

6. Tecniche di Testing Avanzate

Dopo aver appreso i concetti di base del testing unitario in Go, possiamo esplorare ulteriori tecniche di testing avanzate, utili per costruire software più robusto e migliorare l'efficienza del testing.

6.1 Testing delle Funzioni Private

In Golang, le funzioni private si riferiscono tipicamente a funzioni non esportate, cioè funzioni il cui nome inizia con una lettera minuscola. Solitamente preferiamo testare le interfacce pubbliche, in quanto riflettono l'usabilità del codice. Tuttavia, ci sono casi in cui ha senso testare direttamente le funzioni private, ad esempio quando la funzione privata ha una logica complessa ed è chiamata da molteplici funzioni pubbliche.

Testare le funzioni private differisce dal test delle funzioni pubbliche perché non possono essere accessibili esternamente al package. Una tecnica comune è scrivere il codice di test nello stesso package, permettendo l'accesso alla funzione privata.

Ecco un semplice esempio:

// calculator.go
package calculator

func add(a, b int) int {
    return a + b
}

Il file di test corrispondente è il seguente:

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    expected := 4
    actual := add(2, 2)
    if actual != expected {
        t.Errorf("previsto %d, ottenuto %d", expected, actual)
    }
}

Posizionando il file di test nello stesso package, possiamo testare direttamente la funzione add.

6.2 Pattern Comuni e Migliori Pratiche nel Testing

Il testing unitario in Go presenta alcuni pattern comuni che facilitano il lavoro di testing e aiutano a mantenere la chiarezza e la manutenibilità del codice.

  1. Test basati su tabelle

    Il testing basato su tabelle è un metodo per organizzare input di test e output attesi. Definendo un insieme di casi di test e quindi iterando attraverso di essi per il testing, questo metodo rende molto facile aggiungere nuovi casi di test e rende anche il codice più leggibile e mantenibile.

    // calculator_test.go
    package calculator

    import "testing"

    func TestAddTableDriven(t *testing.T) {
        var tests = []struct {
            a, b   int
            want   int
        }{
            {1, 2, 3},
            {2, 2, 4},
            {5, -1, 4},
        }

        for _, tt := range tests {
            testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
            t.Run(testname, func(t *testing.T) {
                ans := add(tt.a, tt.b)
                if tt.want != ans {
                    t.Errorf("ottenuto %d, previsto %d", ans, tt.want)
                }
            })
        }
    }
  1. Utilizzo di Mock per il Testing

    Il mocking è una tecnica di testing che prevede la sostituzione delle dipendenze per testare varie parti della funzionalità. In Golang, le interfacce sono il principale modo per implementare i mock. Utilizzando le interfacce, è possibile creare un'implementazione mock e quindi utilizzarla nel testing.