Testarea aplicației Go: Începeți cum trebuie

Publicat: 2022-03-11

Când înveți ceva nou, este important să ai o stare de spirit proaspătă.

Dacă sunteți destul de nou în Go și proveniți din limbi precum JavaScript sau Ruby, probabil că sunteți obișnuit să utilizați cadrele existente care vă ajută să bate joc, să afirmați și să faceți alte vrăjitorie de testare.

Acum eradicați ideea de dependență de dependențe sau cadre externe! Testarea a fost primul impediment de care m-am lovit când am învățat acest limbaj de programare remarcabil acum câțiva ani, o perioadă în care erau mult mai puține resurse disponibile.

Acum știu că a testa succesul în Go înseamnă să călătorești ușor pe dependențe (ca și în cazul tuturor lucrurilor Go), să te bazezi minim pe biblioteci externe și să scrii un cod bun reutilizabil. Această prezentare a experiențelor lui Blake Mizerany de a se aventura cu biblioteci de testare terță parte este un început excelent pentru a vă ajusta mentalitatea. Veți vedea câteva argumente bune despre utilizarea bibliotecilor și cadrelor externe față de a face asta „modul Go”.

Vrei să înveți Go? Consultați tutorialul nostru introductiv Golang.

Poate părea contra-intuitiv să-ți construiești propriul cadru de testare și concepte batjocoritoare, dar este mai ușor decât s-ar crede și un bun punct de plecare pentru a învăța limba. În plus, spre deosebire de când învățam, aveți acest articol pentru a vă ghida prin scenariile comune de testare, precum și pentru a introduce tehnici pe care le consider cele mai bune practici pentru testarea eficientă și păstrarea curată a codului.

Faceți lucrurile „The Go Way”, eradicați dependențele de cadre externe.
Tweet

Testarea la masă în Go

Unitatea de testare de bază - cu faima de „testare unitară” - poate fi orice componentă a unui program în forma sa cea mai simplă care preia o intrare și returnează o ieșire. Să aruncăm o privire la o funcție simplă pentru care am dori să scriem teste. Nu este nici pe departe perfect sau complet, dar este suficient de bun pentru scopuri demonstrative:

avg.go

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

Funcția de mai sus, func Avg(nos ...int) , returnează fie zero, fie media întreagă a unei serii de numere care îi sunt date. Acum să scriem un test pentru el.

În Go, se consideră cea mai bună practică de a numi un fișier de test cu același nume ca fișierul care conține codul testat, cu sufixul adăugat _test . De exemplu, codul de mai sus se află într-un fișier numit avg.go , așa că fișierul nostru de testare va fi numit avg_test.go .

Rețineți că aceste exemple sunt doar fragmente din fișiere reale, deoarece definiția pachetului și importurile au fost omise pentru simplitate.

Iată un test pentru funcția 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) } } }

Există câteva lucruri de reținut despre definiția funcției:

  • În primul rând, prefixul „Test” de pe numele funcției de testare. Acest lucru este necesar pentru ca instrumentul să îl preia ca un test valid.
  • Ultima parte a numelui funcției este, în general, numele funcției sau metodei testate, în acest caz Avg .
  • De asemenea, trebuie să trecem în structura de testare numită testing.T , care permite controlul fluxului testului. Pentru mai multe detalii despre acest API, vă rugăm să vizitați pagina de documentație.

Acum să vorbim despre forma în care este scris exemplul. O suită de teste (o serie de teste) este rulată prin funcția Avg() și fiecare test conține o intrare specifică și ieșirea așteptată. În cazul nostru, fiecare test trimite o felie de numere întregi ( Nos ) și așteaptă o anumită valoare de returnare ( Result ).

Testarea tabelului își ia numele de la structura sa, ușor reprezentată printr-un tabel cu două coloane: variabila de intrare și variabila de ieșire așteptată.

Batjocorirea interfeței Golang

Una dintre cele mai mari și mai puternice caracteristici pe care limba Go le are de oferit este numită interfață. Pe lângă puterea și flexibilitatea pe care le obținem din interfață atunci când ne proiectăm programele, interfațarea ne oferă, de asemenea, oportunități uimitoare de a ne decupla componentele și de a le testa temeinic la punctul de întâlnire.

O interfață este o colecție numită de metode, dar și un tip de variabilă.

Să luăm un scenariu imaginar în care trebuie să citim primii N octeți dintr-un io.Reader și să îi returnăm ca șir. Ar arata cam asa:

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 }

Evident, principalul lucru de testat este că funcția readN , atunci când i se oferă diverse intrări, returnează ieșirea corectă. Acest lucru se poate face cu testarea pe tabel. Dar există alte două aspecte netriviale pe care ar trebui să le acoperim, care verifică că:

  • r.Read este apelat cu un buffer de dimensiunea n.
  • r.Read returnează o eroare dacă se aruncă una.

Pentru a cunoaște dimensiunea buffer-ului care este transmis către r.Read , precum și pentru a prelua controlul asupra erorii pe care o returnează, trebuie să batem joc de r care este transmis către readN . Dacă ne uităm la documentația Go pe tip Reader, vedem cum arată io.Reader :

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

Asta pare destul de ușor. Tot ceea ce trebuie să facem pentru a satisface io.Reader este să avem propria noastră metodă de Read simulată. Deci, ReaderMock -ul nostru poate fi după cum urmează:

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

Să analizăm puțin codul de mai sus. Orice instanță de ReaderMock satisface în mod clar interfața io.Reader deoarece implementează metoda Read necesară. Mock-ul nostru conține, de asemenea, câmpul ReadMock , permițându-ne să setăm comportamentul exact al metodei batjocorite, ceea ce ne face foarte ușor să instanțiem dinamic orice avem nevoie.

Un truc grozav fără memorie pentru a vă asigura că interfața este satisfăcută în timpul rulării este să introduceți următoarele în codul nostru:

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

Acest lucru verifică afirmația, dar nu alocă nimic, ceea ce ne permite să ne asigurăm că interfața este implementată corect în timpul compilării, înainte ca programul să se întâmple efectiv cu orice funcționalitate care o folosește. Un truc opțional, dar util.

Mergând mai departe, să scriem primul nostru test, în care r.Read este apelat cu un buffer de dimensiunea n . Pentru a face acest lucru, folosim ReaderMock -ul nostru după cum urmează:

 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) } }

După cum puteți vedea mai sus, am definit comportamentul pentru funcția de Read a io.Reader -ului nostru „fals” cu o variabilă de domeniu, care poate fi folosită ulterior pentru a afirma validitatea testului nostru. Destul de usor.

Să ne uităm la cel de-al doilea scenariu pe care trebuie să-l testăm, care ne cere să ne batem joc de Read pentru a returna o eroare:

 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") } }

În testarea de mai sus, orice apel către mr.Read (Cititorul nostru batjocorit) va returna eroarea definită, astfel încât este sigur să presupunem că funcționarea corectă a readN va face același lucru.

Funcția Mocking with Go

Nu se întâmplă adesea să avem nevoie să batem joc de o funcție, deoarece avem tendința de a folosi în schimb structuri și interfețe. Acestea sunt mai ușor de controlat, dar ocazional ne putem întâlni cu această necesitate și văd frecvent confuzie în jurul subiectului. Unii oameni au întrebat chiar cum să bată joc de lucruri precum log.Println . Deși rareori trebuie să testăm intrarea dată la log.Println , vom folosi această oportunitate pentru a demonstra.

Luați în considerare această declarație if simplă de mai jos, care înregistrează ieșirea în funcție de valoarea lui n :

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

În exemplul de mai sus, presupunem scenariul ridicol în care testăm în mod specific acel log. log.Println este apelat cu valorile corecte. Pentru a ne batjocori această funcție, trebuie să o înfășurăm mai întâi în interiorul nostru:

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

Declararea funcției în acest mod - ca variabilă - ne permite să o suprascriem în testele noastre și să îi atribuim orice comportament dorim. Implicit, liniile care se referă la log.Println sunt înlocuite cu show , astfel încât programul nostru devine:

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

Acum putem testa:

 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 }

Rezultatul nostru nu ar trebui să fie „mock log.Println ”, dar în acele scenarii foarte ocazionale când trebuie să batem joc de o funcție la nivel de pachet din motive legitime, singura modalitate de a face acest lucru (din câte știu eu) este declarându-l ca o variabilă la nivel de pachet, astfel încât să putem prelua controlul asupra valorii sale.

Cu toate acestea, dacă vreodată trebuie să batem joc de lucruri precum log.Println , o soluție mult mai elegantă poate fi scrisă dacă ar fi să folosim un logger personalizat.

Go Teste de randare șabloane

Un alt scenariu destul de comun este testarea faptului că rezultatul unui șablon randat este conform așteptărilor. Să luăm în considerare o solicitare GET către http://localhost:3999/welcome?name=Frank , care returnează următorul corp:

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

În cazul în care nu era suficient de evident până acum, nu este o coincidență faptul că name parametrului de interogare se potrivește cu conținutul span clasificat ca „nume”. În acest caz, testul evident ar fi să verificăm dacă acest lucru se întâmplă corect de fiecare dată pe mai multe ieșiri. Am găsit că biblioteca GoQuery este extrem de utilă aici.

GoQuery folosește un API asemănător jQuery pentru a interoga o structură HTML, care este indispensabilă pentru testarea validității rezultatelor de marcare a programelor dvs.

Acum putem scrie testul nostru în acest fel:

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) } }

În primul rând, verificăm dacă codul de răspuns a fost 200/OK înainte de a continua.

Cred că nu este prea exagerat să presupunem că restul fragmentului de cod de mai sus se explică de la sine: recuperăm adresa URL folosind pachetul http și creăm un nou document compatibil cu goquery din răspuns, pe care apoi îl folosim pentru a interoga. DOM-ul care a fost returnat. Verificăm dacă span.name din interiorul h1.header-name încapsulează textul „Frank”.

Testarea API-urilor JSON

Go este frecvent folosit pentru a scrie API-uri de un fel, așa că, nu în ultimul rând, să analizăm câteva modalități la nivel înalt de testare a API-urilor JSON.

Luați în considerare dacă punctul final a returnat anterior JSON în loc de HTML, deci de la http://localhost:3999/welcome.json?name=Frank ne-am aștepta ca corpul răspunsului să arate ceva de genul:

 {"Salutation": "Hello Frank!"}

Afirmarea răspunsurilor JSON, așa cum s-ar fi putut ghici deja, nu este mult diferită de afirmarea răspunsurilor șablon, cu excepția faptului că nu avem nevoie de biblioteci sau dependențe externe. Bibliotecile standard ale Go sunt suficiente. Iată testul nostru care confirmă că JSON corect este returnat pentru parametrii dați:

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) } }

Dacă ar fi returnat altceva decât structura pe care o decodăm, json.NewDecoder va returna o eroare și testul va eșua. Având în vedere că răspunsul decodifică cu succes în raport cu structura, verificăm dacă conținutul câmpului este conform așteptărilor - în cazul nostru „Hello Frank!”.

Configurare și demontare

Testarea cu Go este ușoară, dar există o problemă atât cu testul JSON de mai sus, cât și cu testul de randare a șablonului înainte de aceasta. Amândoi presupun că serverul rulează, iar acest lucru creează o dependență nesigură. De asemenea, nu este o idee grozavă să mergi împotriva unui server „în direct”.

Nu este niciodată o idee bună să testați datele „în direct” pe un server de producție „în direct”; rotiți copii locale sau de dezvoltare, astfel încât să nu se facă daune cu lucrurile care merg groaznic de rău.

Din fericire, Go oferă pachetul httptest pentru a crea servere de testare. Testele declanșează propriul server separat, independent de cel principal, astfel încât testarea nu va interfera cu producția.

În aceste cazuri, este ideal să creați funcții generice setup și teardown care să fie apelate de toate testele care necesită un server care rulează. Urmând acest model nou, mai sigur, testele noastre ar ajunge să arate așa:

 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) }

Rețineți referința app.Handler() . Aceasta este o funcție de cea mai bună practică care returnează http.Handler-ul aplicației, care poate instanția fie serverul de producție, fie un server de testare.

Concluzie

Testarea în Go este o oportunitate grozavă de a-ți asuma perspectiva exterioară a programului tău și de a lua pantofii vizitatorilor tăi sau, în majoritatea cazurilor, a utilizatorilor API-ului tău. Oferă o oportunitate excelentă de a vă asigura că oferiți cod bun și o experiență de calitate.

Ori de câte ori nu sunteți sigur de funcționalitățile mai complexe din codul dvs., testarea este utilă ca o asigurare și, de asemenea, garantează că piesele vor continua să funcționeze bine împreună atunci când se modifică părți ale sistemelor mai mari.

Sper că acest articol v-a fost de folos și sunteți binevenit să comentați dacă cunoașteți alte trucuri de testare.