Testare la tua app Go: inizia nel modo giusto

Pubblicato: 2022-03-11

Quando si impara qualcosa di nuovo, è importante avere un nuovo stato d'animo.

Se sei abbastanza nuovo in Go e provieni da linguaggi come JavaScript o Ruby, probabilmente sei abituato a utilizzare i framework esistenti che ti aiutano a deridere, affermare e fare altre magie di test.

Ora sradica l'idea di fare affidamento su dipendenze o framework esterni! I test sono stati il ​​primo ostacolo in cui mi sono imbattuto durante l'apprendimento di questo straordinario linguaggio di programmazione un paio di anni fa, un periodo in cui c'erano molte meno risorse disponibili.

Ora so che testare il successo in Go significa viaggiare leggero sulle dipendenze (come con tutte le cose Go), fare affidamento minimamente su librerie esterne e scrivere un buon codice riutilizzabile. Questa presentazione delle esperienze di Blake Mizerany che si avventura con librerie di test di terze parti è un ottimo inizio per adattare la tua mentalità. Vedrai alcuni buoni argomenti sull'utilizzo di librerie e framework esterni rispetto a farlo "the Go way".

Vuoi imparare Vai? Dai un'occhiata al nostro tutorial introduttivo al Golang.

Può sembrare controintuitivo costruire il proprio framework di test e concetti beffardi, ma è più facile di quanto si possa pensare e un buon punto di partenza per l'apprendimento della lingua. Inoltre, a differenza di quando stavo imparando, hai questo articolo per guidarti attraverso scenari di test comuni e per introdurre tecniche che considero best practice per testare in modo efficiente e mantenere pulito il codice.

Fai le cose "the Go Way", sradica le dipendenze da strutture esterne.
Twitta

Test da tavolo in Go

L'unità di test di base - della fama di "unit test" - può essere qualsiasi componente di un programma nella sua forma più semplice che accetta un input e restituisce un output. Diamo un'occhiata a una semplice funzione per la quale vorremmo scrivere dei test. Non è neanche lontanamente perfetto o completo, ma è abbastanza buono a scopo dimostrativo:

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

La funzione precedente, func Avg(nos ...int) , restituisce zero o la media intera di una serie di numeri che le vengono dati. Ora scriviamo un test per questo.

In Go, è considerata una buona pratica nominare un file di test con lo stesso nome del file che contiene il codice da testare, con il suffisso aggiunto _test . Ad esempio, il codice sopra è in un file chiamato avg.go , quindi il nostro file di test sarà chiamato avg_test.go .

Si noti che questi esempi sono solo estratti di file effettivi, poiché la definizione del pacchetto e le importazioni sono state omesse per semplicità.

Ecco un test per la funzione Avg :

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

Ci sono diverse cose da notare sulla definizione della funzione:

  • Innanzitutto, il prefisso 'Test' sul nome della funzione di test. Ciò è necessario affinché lo strumento lo rilevi come test valido.
  • L'ultima parte del nome della funzione è generalmente il nome della funzione o del metodo da testare, in questo caso Avg .
  • Abbiamo anche bisogno di passare nella struttura di test chiamata testing.T , che consente il controllo del flusso del test. Per maggiori dettagli su questa API, visita la pagina della documentazione.

Ora parliamo della forma in cui è scritto l'esempio. Una suite di test (una serie di test) viene eseguita tramite la funzione Avg() e ogni test contiene un input specifico e l'output previsto. Nel nostro caso, ogni test invia una fetta di numeri interi ( Nos ) e si aspetta un valore di ritorno specifico ( Result ).

Il test della tabella prende il nome dalla sua struttura, facilmente rappresentata da una tabella con due colonne: la variabile di input e la variabile di output prevista.

Scherzo dell'interfaccia Golang

Una delle funzionalità più grandi e potenti che il linguaggio Go ha da offrire è chiamata interfaccia. Oltre alla potenza e alla flessibilità che otteniamo dall'interfacciamento durante l'architettura dei nostri programmi, l'interfacciamento ci offre anche straordinarie opportunità per disaccoppiare i nostri componenti e testarli a fondo nel punto di incontro.

Un'interfaccia è una raccolta denominata di metodi, ma anche un tipo variabile.

Prendiamo uno scenario immaginario in cui dobbiamo leggere i primi N byte da un io.Reader e restituirli come stringa. Sembrerebbe qualcosa del genere:

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

Ovviamente, la cosa principale da testare è che la funzione readN , quando riceve vari input, restituisce l'output corretto. Questo può essere fatto con il test della tabella. Ma ci sono altri due aspetti non banali di cui dovremmo occuparci, che stanno verificando che:

  • r.Read viene chiamato con un buffer di dimensione n.
  • r.Read restituisce un errore se ne viene generato uno.

Per conoscere la dimensione del buffer che viene passato a r.Read , oltre a prendere il controllo dell'errore che restituisce, dobbiamo prendere in giro la r passata a readN . Se osserviamo la documentazione Go sul tipo Reader, vediamo come appare io.Reader :

 type Reader interface { Read(p []byte) (n int, err error) }

Sembra piuttosto facile. Tutto quello che dobbiamo fare per soddisfare io.Reader è avere il nostro finto metodo Read . Quindi il nostro ReaderMock può essere il seguente:

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

Analizziamo un po' il codice sopra. Qualsiasi istanza di ReaderMock soddisfa chiaramente l'interfaccia io.Reader perché implementa il metodo Read necessario. Il nostro mock contiene anche il campo ReadMock , che ci consente di impostare il comportamento esatto del metodo mocked, il che rende super facile per noi istanziare dinamicamente qualsiasi cosa di cui abbiamo bisogno.

Un ottimo trucco senza memoria per garantire che l'interfaccia sia soddisfatta in fase di esecuzione è inserire quanto segue nel nostro codice:

 var _ io.Reader = (*MockReader)(nil)

Questo controlla l'asserzione ma non alloca nulla, il che ci consente di assicurarci che l'interfaccia sia implementata correttamente in fase di compilazione, prima che il programma esegua effettivamente qualsiasi funzionalità che lo utilizza. Un trucco facoltativo, ma utile.

Andando avanti, scriviamo il nostro primo test, in cui r.Read viene chiamato con un buffer di dimensione n . Per fare ciò, utilizziamo il nostro ReaderMock come segue:

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

Come puoi vedere sopra, abbiamo definito il comportamento per la funzione Read del nostro io.Reader “falso” con una variabile scope, che può essere successivamente utilizzata per affermare la validità del nostro test. Abbastanza facile.

Diamo un'occhiata al secondo scenario che dobbiamo testare, che richiede di prendere in giro Read per restituire un errore:

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

Nel test di cui sopra, qualsiasi chiamata a mr.Read (il nostro lettore deriso) restituirà l'errore definito, quindi è lecito ritenere che il corretto funzionamento di readN farà lo stesso.

Funzione beffardo con Go

Non capita spesso di dover prendere in giro una funzione, perché tendiamo invece a utilizzare strutture e interfacce. Questi sono più facili da controllare, ma occasionalmente possiamo imbatterci in questa necessità e vedo spesso confusione sull'argomento. Alcune persone hanno persino chiesto come prendere in giro cose come log.Println . Sebbene sia raro che sia necessario testare l'input fornito a log.Println , utilizzeremo questa opportunità per dimostrare.

Considera questa semplice istruzione if di seguito che registra l'output in base al valore di n :

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

Nell'esempio precedente, assumiamo lo scenario ridicolo in cui testiamo specificamente che log.Println viene chiamato con i valori corretti. Per poter deridere questa funzione, dobbiamo prima avvolgerla all'interno della nostra:

 var show = func(v ...interface{}) { log.Println(v...) }

Dichiarare la funzione in questo modo - come una variabile - ci consente di sovrascriverla nei nostri test e di assegnarle qualsiasi comportamento desideriamo. Implicitamente, le righe che fanno riferimento a log.Println vengono sostituite con show , quindi il nostro programma diventa:

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

Ora possiamo testare:

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

Il nostro takeaway non dovrebbe essere "mock log.Println ", ma in quegli scenari molto occasionali in cui abbiamo bisogno di deridere una funzione a livello di pacchetto per motivi legittimi, l'unico modo per farlo (per quanto ne so) è dichiarandola come variabile a livello di pacchetto in modo da poter assumere il controllo del suo valore.

Tuttavia, se dovessimo mai prendere in giro cose come log.Println , una soluzione molto più elegante può essere scritta se dovessimo usare un logger personalizzato.

Vai ai test di rendering del modello

Un altro scenario abbastanza comune consiste nel verificare che l'output di un modello sottoposto a rendering sia conforme alle aspettative. Consideriamo una richiesta GET a http://localhost:3999/welcome?name=Frank , che restituisce il seguente corpo:

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

Nel caso in cui non fosse ormai abbastanza ovvio, non è un caso che il name del parametro della query corrisponda al contenuto span classificato come “nome”. In questo caso, il test ovvio sarebbe quello di verificare che ciò avvenga correttamente ogni volta su più uscite. Ho trovato la libreria GoQuery estremamente utile qui.

GoQuery utilizza un'API simile a jQuery per interrogare una struttura HTML, indispensabile per testare la validità dell'output di markup dei tuoi programmi.

Ora possiamo scrivere il nostro test in questo modo:

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

Innanzitutto, controlliamo che il codice di risposta fosse 200/OK prima di procedere.

Credo che non sia troppo inverosimile presumere che il resto del frammento di codice sopra sia autoesplicativo: recuperiamo l'URL usando il pacchetto http e creiamo un nuovo documento compatibile con goquery dalla risposta, che poi usiamo per interrogare il DOM restituito. Verifichiamo che span.name all'interno h1.header-name incapsula il testo 'Frank'.

Test delle API JSON

Go viene spesso utilizzato per scrivere API di qualche tipo, quindi, ultimo ma non meno importante, esaminiamo alcuni modi di alto livello per testare le API JSON.

Considera se l'endpoint ha precedentemente restituito JSON anziché HTML, quindi da http://localhost:3999/welcome.json?name=Frank ci aspetteremmo che il corpo della risposta assomigli a qualcosa del tipo:

 {"Salutation": "Hello Frank!"}

Affermare le risposte JSON, come si potrebbe già intuire, non è molto diverso dall'asserire le risposte del modello, con l'eccezione che non abbiamo bisogno di librerie o dipendenze esterne. Le librerie standard di Go sono sufficienti. Ecco il nostro test che conferma che viene restituito il JSON corretto per i parametri indicati:

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

Se dovesse essere restituito qualcosa di diverso dalla struttura che decodifichiamo, json.NewDecoder restituirà invece un errore e il test avrà esito negativo. Considerando che la risposta decodifica con successo rispetto alla struttura, controlliamo che i contenuti del campo siano come previsto - nel nostro caso "Ciao Frank!".

Installazione e smontaggio

Il test con Go è facile, ma c'è un problema sia con il test JSON sopra che con il test di rendering del modello precedente. Entrambi presumono che il server sia in esecuzione e questo crea una dipendenza inaffidabile. Inoltre, non è una buona idea andare contro un server "live".

Non è mai una buona idea testare dati "live" su un server di produzione "live"; crea copie locali o di sviluppo in modo che non ci siano danni causati dalle cose che vanno terribilmente storte.

Fortunatamente, Go offre il pacchetto httptest per creare server di test. I test attivano un server separato, indipendente dal nostro principale, e quindi i test non interferiscono con la produzione.

In questi casi è ideale creare funzioni di setup e teardown generiche da richiamare in tutti i test che richiedono un server in esecuzione. Seguendo questo nuovo schema più sicuro, i nostri test finirebbero per assomigliare a questo:

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

Nota il riferimento app.Handler() . Questa è una funzione di best practice che restituisce http.Handler dell'applicazione, che può creare un'istanza del server di produzione o di un server di test.

Conclusione

Il test in Go è una grande opportunità per assumere la prospettiva esterna del tuo programma e vestire i panni dei tuoi visitatori o, nella maggior parte dei casi, degli utenti della tua API. Offre la grande opportunità di assicurarsi di fornire un buon codice e un'esperienza di qualità.

Ogni volta che non sei sicuro delle funzionalità più complesse nel tuo codice, il test è utile come rassicurazione e garantisce anche che i pezzi continueranno a funzionare bene insieme quando si modificano parti di sistemi più grandi.

Spero che questo articolo ti sia stato utile e sei libero di commentare se conosci altri trucchi per i test.