Când scrieți teste de unitate, nu folosiți simulari
Publicat: 2018-05-02Notă: Acesta este cel mai recent post al nostru de inginerie tehnică scris de inginerul principal, Seth Ammons. Mulțumiri speciale lui Sam Nguyen, Kane Kim, Elmer Thomas și Kevin Gillette pentru evaluarea de către colegi a acestei postări . Și pentru mai multe postări ca acesta, consultați blogul nostru tehnic.
Îmi place foarte mult să scriu teste pentru codul meu, în special teste unitare. Sentimentul de încredere pe care mi-l dă este grozav. Alegând ceva la care nu am mai lucrat de mult timp și a putea rula testele unitare și de integrare îmi oferă cunoștințele că pot refactor fără milă dacă este nevoie și, atâta timp cât testele mele au o acoperire bună și semnificativă și continuă să treacă. , voi avea în continuare software funcțional după aceea.
Testele unitare ghidează proiectarea codului și ne permit să verificăm rapid dacă modurile de defecțiune și fluxurile logice funcționează conform intenției. Cu asta, vreau să scriu despre ceva poate un pic mai controversat: atunci când scrii teste unitare, nu folosi bateriile.
Să punem pe masă câteva definiții
Care este diferența dintre testele unitare și cele de integrare? Ce vreau să spun prin batjocuri și ce ar trebui să folosești în schimb? Această postare se concentrează pe lucrul în Go, așa că înclinația mea asupra acestor cuvinte este în contextul Go.
Când spun teste unitare , mă refer la acele teste care asigură gestionarea corectă a erorilor și ghidează proiectarea sistemului prin testarea unor unități mici de cod. După unitate, ne-am putea referi la un pachet întreg, o interfață sau o metodă individuală.
Testarea integrării este locul în care interacționați efectiv cu sisteme și/sau biblioteci dependente. Când spun „batjocuri”, mă refer în mod specific la termenul „Obiect simulat”, care este în cazul în care „înlocuim codul de domeniu cu implementări false care atât emulează funcționalitatea reală, cât și impun aserțiuni despre comportamentul codului nostru [1]” (subliniere). A mea).
Afirmat puțin mai scurt: batjocorește comportamentul afirmării, cum ar fi:
MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")
Pled pentru „falsuri mai degrabă decât batjocuri”.
Un fals este un fel de test dublu care poate conține comportament de afaceri [2]. Falsurile sunt doar structuri care se potrivesc unei interfețe și sunt o formă de injectare a dependenței în care controlăm comportamentul. Beneficiul major al falsurilor este că reduc cuplarea în cod, unde batjocurile cresc cuplarea, iar cuplarea îngreunează refactorizarea [3].
În această postare, intenționez să demonstrez că falsurile oferă flexibilitate și permit testarea și refactorizarea ușoară. Acestea reduc dependențele în comparație cu batjocuri și sunt ușor de întreținut.
Să trecem cu un exemplu care este puțin mai avansat decât „testarea unei funcții de sumă”, așa cum ați putea vedea într-o postare tipică de această natură. Cu toate acestea, trebuie să vă ofer un context, astfel încât să puteți înțelege mai ușor codul care urmează în această postare.
La SendGrid, unul dintre sistemele noastre a avut în mod tradițional fișiere pe sistemul de fișiere local, dar din cauza necesității unei disponibilități mai mari și a unui debit mai bun, mutăm aceste fișiere pe S3.
Avem o aplicație care trebuie să poată citi aceste fișiere și am optat pentru o aplicație care poate rula în două moduri „local” sau „la distanță”, în funcție de configurație. O avertizare care este eliminată în multe dintre exemplele de cod este că, în cazul unei erori de la distanță, revenim la citirea fișierului local.
Cu asta din drum, această aplicație are un colector de pachete. Trebuie să ne asigurăm că pachetul de colectare poate obține fișiere fie de la sistemul de fișiere la distanță, fie de la sistemul de fișiere local.
Abordare naivă: apelați doar la bibliotecă și la nivel de sistem
Abordarea naivă este că pachetul nostru de implementare va apela getter.New(...) și îi va transmite informațiile necesare pentru setarea fie la distanță sau locală a obținerii fișierelor și va returna un Getter . Valoarea returnată va putea apoi să apeleze MyGetter.GetFile(...) cu parametrii necesari pentru localizarea fișierului la distanță sau local.
Acest lucru ne va oferi structura noastră de bază. Când creăm noul Getter , inițializam parametrii necesari pentru orice potențial preluare de fișiere de la distanță (o cheie de acces și un secret) și, de asemenea, transmitem unele valori care provin din configurația aplicației noastre, cum ar fi useRemoteFS , care va spune codului să încerce. sistemul de fișiere la distanță.
Trebuie să oferim unele funcționalități de bază. Consultați codul naiv aici [4]; mai jos este o versiune redusă. Rețineți, acesta este un exemplu neterminat și vom refactoriza lucrurile.
Ideea de bază aici este că, dacă suntem configurați să citim din sistemul de fișiere la distanță și obținem detalii despre sistemul de fișiere la distanță (gazdă, găleată și cheie), atunci ar trebui să încercăm să citim din sistemul de fișiere la distanță. După ce avem încredere în citirea sistemului de la distanță, vom muta toate citirea fișierelor în sistemul de fișiere la distanță și vom elimina referințele la citire din sistemul de fișiere local.
Acest cod nu este foarte prietenos cu testele unitare; rețineți că pentru a verifica modul în care funcționează, trebuie de fapt să lovim nu numai sistemul de fișiere local, ci și sistemul de fișiere de la distanță. Acum, am putea doar să facem un test de integrare și să setăm niște magie Docker pentru a avea o instanță s3 care să ne permită să verificăm calea fericită în cod.
A avea doar testarea integrării este mai puțin decât ideal, deși testele unitare ne ajută să proiectăm software mai robust, testând cu ușurință codul alternativ și căile de eșec. Ar trebui să salvăm testele de integrare pentru tipuri mai mari de teste „funcționează cu adevărat”. Deocamdată, să ne concentrăm pe testele unitare.
Cum putem face acest cod mai testabil? Există două școli de gândire. Una este să utilizați un generator de simulare (cum ar fi https://github.com/vektra/mockery sau https://github.com/golang/mock) care creează un cod standard pentru a fi utilizat la testarea modelelor.
Puteți merge pe această rută și puteți genera apelurile la sistemul de fișiere și apelurile clientului Minio. Sau poate doriți să evitați o dependență, așa că vă generați simulacrele manual. Se pare că batjocorirea clientului Minio nu este simplă, deoarece aveți un client tipat concret care returnează un obiect tipizat concret.
Eu spun că există o cale mai bună decât să batjocorească. Dacă ne restructuram codul pentru a fi mai testabil, nu avem nevoie de importuri suplimentare pentru false și documente aferente și nu va fi nevoie să cunoaștem DSL-uri de testare suplimentare pentru a testa cu încredere interfețele. Ne putem configura codul să nu fie prea cuplat, iar codul de testare va fi doar un cod Go normal folosind interfețele Go. S-o facem!
Abordarea interfeței: abstracție mai mare, testare mai ușoară
Ce trebuie să testăm? Aici unii noi Gopher greșesc lucrurile. Am văzut oameni înțelegând valoarea valorificării interfețelor, dar simt că au nevoie de interfețe care se potrivesc cu implementarea concretă a pachetului pe care îl folosesc.
S-ar putea să vadă că avem un client Minio, așa că ar putea începe prin a crea interfețe care să se potrivească cu TOATE metodele și utilizările clientului Minio (sau oricărui alt client s3). Ei uită Proverbul Go [5][6] din „Cu cât interfața este mai mare, cu atât abstracția este mai slabă”.
Nu trebuie să testăm împotriva clientului Minio. Trebuie să testăm că putem obține fișiere de la distanță sau local (și să verificăm unele căi de eșec, cum ar fi eșecurile de la distanță). Să refactorăm acea abordare inițială și să scoatem clientul Minio într-un getter de la distanță. În timp ce facem asta, să facem același lucru cu codul nostru pentru citirea fișierelor locale și să facem un getter local. Iată interfețele de bază și va trebui să le implementăm fiecare:
Cu aceste abstracții în loc, putem refactoriza implementarea noastră inițială. Vom pune localFetcher și remoteFetcher în structura Getter și vom refactoriza GetFile pentru a le folosi. Consultați versiunea completă a codului refactorizat aici [7]. Mai jos este un fragment ușor simplificat folosind noua versiune de interfață:
Acest cod nou, refactorizat este mult mai testabil unitar, deoarece luăm interfețele ca parametri în structura Getter și putem schimba tipurile concrete pentru falsuri. În loc să ne batem joc de apelurile OS sau să avem nevoie de o batjocură completă a clientului Minio sau a interfețelor mari, avem nevoie doar de două falsuri simple: fakeLocalFetcher și fakeRemoteFetcher .
Aceste falsuri au unele proprietăți asupra lor care ne permit să specificăm ce returnează. Vom putea returna datele fișierului sau orice eroare care ne place și putem verifica că metoda GetFile care apelează gestionează datele și erorile așa cum ne-am propus.
Având în vedere acest lucru, inima testelor devine:
Cu această structură de bază, le putem încheia totul în teste conduse de tabel [8]. Fiecare caz din tabelul de teste va testa fie accesul la fișiere local, fie la distanță. Vom putea injecta o eroare fie la accesul la fișier la distanță, fie la cel local. Putem verifica erorile propagate, dacă conținutul fișierului este transmis și că sunt prezente intrările de jurnal așteptate.
Am continuat și am inclus toate cazurile de testare potențiale și permutările în testul bazat pe tabel disponibil aici [9] (s-ar putea să observați că unele semnături de metodă sunt puțin diferite - ne permite să facem lucruri precum injectarea unui logger și afirmarea împotriva instrucțiunilor de jurnal). ).
Ingenios, nu? Avem control deplin asupra modului în care dorim să se comporte GetFile și putem afirma împotriva rezultatelor. Am proiectat codul nostru pentru a fi prietenos cu testele unitare și acum putem verifica căile de succes și erori implementate în metoda GetFile .
Codul este slab cuplat și refactorizarea în viitor ar trebui să fie o briză. Am făcut acest lucru scriind un cod simplu Go pe care orice dezvoltator familiarizat cu Go ar trebui să îl poată înțelege și extinde atunci când este necesar.
Mock-uri: cum rămâne cu detaliile de implementare amănunțite?
Ce ne-ar cumpăra batjocoritele pe care nu le-am obține în soluția propusă? O întrebare grozavă care arată un beneficiu pentru o imitație tradițională ar putea fi: „De unde știi că ai apelat clientul s3 cu parametrii corecti? Cu imitații, mă pot asigura că am transmis valoarea cheii parametrului cheie și nu parametrului bucket.”
Aceasta este o preocupare valabilă și ar trebui să fie acoperită de un test undeva . Abordarea de testare pe care o susțin aici nu verifică că ați sunat clientul Minio cu găleata și parametrii cheie în ordinea corectă.
Un citat grozav pe care l-am citit recent spunea: „Bajorarea introduce presupuneri, care introduce risc [10]”. Presupuneți că biblioteca client este implementată corect, presupuneți că toate limitele sunt solide, presupuneți că știți cum se comportă de fapt biblioteca.
Batjocorirea bibliotecii doar bate joc de ipoteze și face testele tale mai fragile și pot fi modificate atunci când actualizați codul (care este ceea ce a concluzionat Martin Fowler în Mocks Aren't Stubs [3]). Când cauciucul se întâlnește cu drumul, va trebui să verificăm dacă folosim de fapt clientul Minio corect și asta înseamnă teste de integrare (acestea ar putea trăi într-o configurație Docker sau într-un mediu de testare). Deoarece vom avea atât teste unitare, cât și teste de integrare, nu este nevoie de un test unitar pentru a acoperi implementarea exactă, deoarece testul de integrare va acoperi asta.
În exemplul nostru, testele unitare ne ghidează proiectarea codului și ne permit să testăm rapid dacă erorile și fluxurile logice funcționează așa cum au fost proiectate, făcând exact ceea ce trebuie să facă.
Pentru unii, ei consideră că aceasta nu este suficientă acoperire a testului unitar. Sunt îngrijorați de punctele de mai sus. Unii vor insista asupra interfețelor în stilul păpușilor rusești în care o interfață returnează o altă interfață care returnează o altă interfață, poate ca următorul:
Și apoi s-ar putea să scoată fiecare parte a clientului Minio în fiecare wrapper și apoi să folosească un generator simulat (adăugând dependențe la versiuni și teste, sporind ipotezele și făcând lucrurile mai fragile). La final, batjocoristul va putea spune ceva de genul:
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – și asta dacă vă puteți aminti incantația corectă pentru acest DSL specific.
Aceasta ar fi multă abstracție suplimentară legată direct de alegerea de implementare a utilizării clientului Minio. Acest lucru va provoca teste fragile atunci când aflăm că trebuie să ne schimbăm ipotezele despre client sau avem nevoie de un client complet diferit.
Acest lucru se adaugă la timpul de dezvoltare a codului de la capăt la capăt acum și în viitor, adaugă la complexitatea codului și reduce lizibilitatea, crește potențial dependențele de generatoarele simulate și ne oferă valoarea suplimentară dubioasă de a ști dacă am amestecat găleata și parametrii cheie. dintre care oricum le-am fi descoperit în testarea integrării.
Pe măsură ce sunt introduse tot mai multe obiecte, cuplajul devine din ce în ce mai strâns. S-ar putea să fi făcut o simulare de logger și mai târziu să începem să avem o simulare de valori. Înainte să știi, adaugi o intrare de jurnal sau o nouă măsurătoare și tocmai ai întrerupt nenumărate teste care nu se așteptau să treacă printr-o măsurătoare suplimentară.
Ultima dată când am fost mușcat de asta în Go, cadrul batjocoritor nici măcar nu mi-a spus ce test sau fișier eșuează, deoarece a intrat în panică și a murit de o moarte îngrozitoare, deoarece a dat peste o nouă măsurătoare (aceasta a necesitat căutarea binară a testelor, comentându-le). pentru a putea găsi unde trebuie să modificăm comportamentul simulat). Batjocurile pot adăuga valoare? Sigur. Merită costul? În cele mai multe cazuri, nu sunt convins.
Interfețe: simplitate și testare unitară pentru câștig
Am arătat că putem ghida proiectarea și ne asigurăm că sunt urmate codurile adecvate și căile de eroare prin utilizarea simplă a interfețelor în Go. Scriind falsuri simple care aderă la interfețe, putem vedea că nu avem nevoie de simulari, cadre batjocoritoare sau generatoare de simulare pentru a crea coduri concepute pentru testare. De asemenea, am observat că testarea unitară nu este totul și trebuie să scrieți teste de integrare pentru a vă asigura că sistemele sunt integrate corect între ele.
Sper să primesc o postare despre câteva modalități bune de a rula teste de integrare în viitor; Rămâneți aproape!
Referințe
1: Endo-Testing: Unit Testing with Mock Objects (2000): Vezi introducerea pentru definiția obiectului batjocorit
2: Micul batjocoritor: vezi partea despre falsuri, în special, „un fals are un comportament de afaceri. Puteți determina un fals să se comporte în moduri diferite, oferindu-i date diferite.”
3: Batjocuri nu sunt cioturi: vezi secțiunea „Deci ar trebui să fiu un clasicist sau un batjocoritor?” Martin Fowler afirmă: „Nu văd niciun beneficiu convingător pentru TDD simulat și sunt îngrijorat de consecințele testelor de cuplare cu implementarea.”
4: Naive Approach: o versiune simplificată a codului. Vezi [7].
5: https://go-proverbs.github.io/: Lista Proverbelor Go cu link-uri către discuții.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: Link direct pentru a vorbi de Rob Pike în ceea ce privește dimensiunea interfeței și abstractizarea.
7: Versiunea completă a codului demo: puteți clona depozitul și rula `go test`.
8: Teste bazate pe tabel: O strategie de testare pentru organizarea codului de testare pentru a reduce dublarea.
9: Teste pentru versiunea completă a codului demo. Le puteți rula cu `go test`.
10: Întrebări pe care să ți le pui când scrii teste de Michal Charemza: Batjocorirea introduce presupuneri, iar ipotezele introduc risc.