ABONAMENTE VIDEO REDACȚIA
RO
EN
×
▼ LISTĂ EDIȚII ▼
Numărul 12
Abonament PDF

Test Driven Development (TDD)

Tudor Trișcă
Team Lead & Scrum Master
@.msg systems Romania
TESTARE

Test Driven Development (TDD) este o abordare a dezvoltării de software ce combină Test First Development (TFD) cu refactorizarea. Legat de scopul test driven development-ului există mai multe puncte de vedere: specificarea codului și nu validarea lui. Cu alte cuvinte, este o cale de a gândi prin prisma cerințelor sau a designului înainte de a ne apuca efectiv a scrie cod funcțional (TDD este o cerință agile - agile requirement - și o tehnică de design agile). Un alt punct de vedere este că TDD reprezintă o tehnică de programare al cărui scop este acela de a scrie cod curat care funcționează.

Voi descrie o situație cu care majoritatea suntem familiari: programatorii scriu codul, iar la un moment dat acest cod ajunge la testare. Testerii descoperă anumite anomalii în funcționalitatea codului, numite bug-uri, pe care le trimit programatorilor spre a fi reparate. După ce programatorul a reparat bug-ul, trimite din nou codul la testare. De cele mai multe ori acest "dialog" programator - tester nu este foarte clar și precis, un bug fiind pasat la tester de către programator ca și rezolvat, tester-ul descoperă că de fapt nu s-a rezolvat și îl trimite înapoi la programator și tot așa. Pentru a nu se ajunge prea des în situația prezentată anterior, una dintre măsurile luate a fost cea ca programatorul să scrie teste înainte de a implementa o anumită funcționalitate. Această tehnică se numește TFD (Test First Development).

Primul pas al TFD este de a se scrie un test care va eşua. Următoarea mișcare e cea de a rula toată suita de teste, sau, pentru a economisi timp se va rula doar un subset al suitei de teste, pentru a se dovedi că într-adevăr testul adăugat va eşua. Urmează să facem o mică modificare în cod în așa fel încât o nouă rulare a testelor să se termine cu succes. În cazul în care n-am reușit acest lucru vom modifica din nou codul și vom rula testele din nou până când acestea se vor termina cu succes. În momentul în care rularea testelor s-a încheiat cu succes putem s-o luăm de la început cu adăugarea unui nou test.

TDD = TFD + Refactoring

Dacă, aplicând TFD, înainte de a trece la scrierea unui nou test curățăm, optimizăm codul înseamnă că aplicăm Test Driven Development, numit și RED - GREEN - REFACTOR workflow.

Regulile TDD:

  • Este permis să scrii cod de producție doar dacă scopul acestui cod este să facă un test ce eşuează să ruleze cu succes.
  • Nu este permis să scrii decât un test care eşuează.

Trebuie să începi prin a scrie un test pentru funcționalitatea pe care vrei s-o implementezi. Conform celei de-a doua reguli nu poți să scrii prea multe teste. Imediat ce rularea unit testului se termină cu eșec trebuie să te oprești din a scrie teste și să începi să scrii cod de producție în așa fel încât rularea testelor să se termine cu succes. Atenție însă la a treia regulă care ne zice că imediatce am scris destul cod de producție pentru a face ca rularea testelor să se termine cu succes, suntem obligați să ne oprim din a scrie mai mult cod și să reluăm scrierea de teste.

Dacă stăm și analizăm puțin cele trei reguli TDD vom realiza că nu putem scrie prea mult cod fără să compilăm, rulăm ceva. Acesta este de fapt scopul nostru. Orice am face: scriem teste, scriem cod de producție sau refactorizăm trebuie să ținem sistemul în execuție. Intervalul de timp dintre rularea testelor poate să fie de ordinul secundelor sau al minutelor. Chiar și o durată de 10 minute poate fi considerată prea mare.

Când aud de aceasta tehnică, mulţi dintre programatori gândesc: "Asta e o aberaţie! O să mă încetinească și mai mult, e o pierdere de timp și de efort. Nu o să mă lase să mă gândesc, nu o să pot face designul cum trebuie, pur și simplu o să strice bunul mers al lucrurilor". Perfect, să ne gândim ce s-ar întâmpla dacă am intra într-un birou în care toți programatorii ar folosi TDD. Alegeți aleator o persoană din respectivul birou în orice moment și întrebați-l care e starea codului. Cu un minut înainte tot codul lor rula. Repet: "Acum un minut tot codul lor rula!". Și nu contează pe cine ai ales sau când l-ai ales, răspunsul e același: "Acum un minut tot codul lor rula!".

Dacă tot codul tău funcționează la fiecare minut, cam cât de des crezi că vei folosi debugger-ul? Răspunsul este: nu prea des. Este mult mai simplu să apeși de câteva ori CTRL+Z să ajungi înapoi la starea în care codul tău funcționa, iar după asta să încerci să rescrii ceea ce ai scris greșit în ultimele minute. Cât timp vei economisi dacă nu faci prea mult debugging? Cât timp petreci acum făcând debugging? Cât timp petreci acum rezolvând bug-urile pe care le-ai descoperit făcând debugging? Ce ai spune dacă ai putea să scazi semnificativ acest timp?

Adevăratele beneficii ale acestei tehnici sunt însă și mai mari: dacă folosești TDD atunci produci câteva teste pe oră. Zeci de teste pe zi. Sute de teste pe lună. Vei ajunge să scrii mii de teste într-un an. Poți să păstrezi aceste teste și să le rulezi oricând vei dori. Când ar trebui să le rulăm? Tot timpul. De fiecare dată când am modificat ceva în cod, indiferent de proporțiile acelei modificări.

De ce nu curățăm codul chiar dacă știm că nu e prea curat? Pentru că ne este frică să nu-l stricăm. Dar dacă avem toate aceste teste putem fi siguri în cea mai mare măsură că nu vom strica codul, iar în cazul în care acest lucru s-ar întâmpla l-am observa imediat. Dacă avem teste vom fi liniștiți în momentul în care facem modificări în cod. Dacă vedem pe undeva cod "murdar" (messy code) putem să-l curățăm fără rezerve. Datorită testelor codul devine maleabil.

Continuăm cu beneficiile TDD: dacă vrei să știi cum să apelezi un anumit API, există un test ce face acest lucru. Dacă vrei să știi cum se creează un anumit obiect, este un test ce face acest lucru. Orice ai vrea să afli despre sistemul existent vei găsi un test care conține răspunsul la întrebarea ta. Testele sunt ca niște mici documente de design, mici exemple de cod care descriu cum funcționează sistemul și cum să-l folosești.

Ați integrat un third party library în proiectul vostru? Primiți cu acest library și un manual plin de documentație la finalul căruia aveți o listă cu câteva exemple. Care dintre cele două le-ați citit? Exemplele, bineînțeles. Acestea sunt unit testele. Cea mai folositoare parte a documentației. Exemple ale modului în care să folosești codul. Acestea sunt documente de design detaliate, clare, ce nu se pot desincroniza în raport cu codul de producție.

Dacă ați încercat să adăugați un unit test la un sistem deja funcțional probabil că ați observat că nu este un lucru trivial. Este posibil ca pentru a face acest lucru să fi fost nevoiţi să schimbați anumite părți ale designului sistemului, sau să trebuiască să păcăliți testele. Acest lucru se datorează faptului că sistemul pentru care încercați să scrieți testele nu a fost proiectat să fie testabil. De exemplu, să presupunem că vreți să testați o anumită metodă, "m". Metoda "m" apelează o altă metodă care șterge o înregistrare din baza de date. În testul nostru nu vrem ca această înregistrare să fie ștearsă din baza de date, dar nu avem cum să împiedicăm acest lucru. Asta înseamnă că sistemul a fost proiectat în așa manieră încât acesta nu este testabil.

Când urmăm cele trei reguli ale TDD tot codul nostru devine testabil prin definiție. Un alt cuvânt pentru "testabil" este "decuplat". Pentru a testa izolat un modul acesta trebuie decuplat. TDD te forțează să decuplezi module, iar aplicând cele trei reguli TDD vei observa că vei folosi decuplarea mult mai des decât erai obișnuit s-o faci până acum. Acest lucru te va face să creezi un design mai bun, mai puțin cuplat (less coupled).

Începerea ciclului Test-Driven

Procesul TDD presupune că putem mări sistemul doar prin inserarea de teste pentru funcţionalităţi noi într-o infrastructură deja existentă. Dar ce se întâmplă cu prima funcţionalitate, înainte să avem infrastructura gata creată? Un test de acceptanţă trebuie să ruleze end-to-end. În plus, trebuie să ne ofere feedback-ul necesar despre interfeţele externe ale sistemului, ceea ce înseamnă că trebuie să avem deja implementat un sistem automatizat de build, deploy şi test. Este multă muncă de depus înainte să putem vedea primul nostru test care eşuează.

Deploy-ul și testarea unui proiect chiar de la început forţează echipa să înţeleagă cum sistemul lor se integrează în lume. Scoate la iveală toată lipsa de cunoştinţe tehnice și riscuri organizaţionale care trebuie adresate cât mai este timp.

Începerea cu un build, deploy, și test automatizat a unui sistem nonexistent sună ciudat, dar este esenţial.Există tot felul de cazuri de proiecte care au fost anulate după câteva luni de dezvoltare deoarece nu puteau face un deploy stabil al sistemului lor. Feedback-ul este o unealtă fundamentală, şi vrem să ştim cât mai repede dacă ne îndreptăm în direcţia potrivită. După ce avem primul test făcut, următoarele vor fi scrise mult mai repede.

Dilema în a scrie şi a trece primul test de acceptanţă stă în faptul că este dificil a face un build al sistemului și a testa noua funcţionalitate implementată în aceelaşi timp. Modificări într-una din cele două perturbă orice progres făcut cu cealaltă. Urmărirea eşecurilor este dificilă atunci când arhitectura, testele și tot codul de producţie sunt în continuă dezvoltare. Unul dintre simptomele unui mediu de dezvoltare instabil este că nu există un prim loc evident de căutare când ceva eşuează.

Acest paradox al primei funcţionalităţi poate fi împărţit în două probleme mai mici. Prima constă în a afla cum se poate face build, deploy și testa acest "walking skeleton". Apoi, se foloseşte această infrastructură nou creată pentru a scrie teste de acceptanţă pentru prima funcţionalitateimportantă. După toate acestea, se poate începe dezvoltarea test-driven a întregului sistem.

Un "walking skeleton" este o implementare a celei mai mici bucăţi de funcţionalitate pe care se poate face build, deploy, și testare end-to-end automatizată. Trebuie incluse doar componentele majore și mecanismele de comunicare care ne ajută la implementarea primei funcţionalităţi. Funcţionalitatea aplicaţiei schelet este atât de simplă încât este evidentă şi neinteresantă, lăsându-ne liberi să ne concentrăm pe infrastructură. De exemplu: pentru o aplicaţie web cu o bază de date, scheletul ar trebui să consiste dintr-o pagină web uniformă cu câmpuri din baza de date.

În timpul construirii scheletului trebuie să ne concentrăm doar pe structură şi nu pe curăţarea testului. Scheletul și infrastructura sa sunt făcute pentru a ne ajuta să începem dezvoltarea test-driven. Este doar primul pas spre o soluţie completă end-to-end de testare de acceptanţă. Când scriem testul pentru prima funcţionalitate, acesta trebuie să fie un test pe care să putem să îl citim, pentru a ne asigura că e o reprezentare clară a comportamentului sistemului.

Dezvoltarea scheletului este momentul în care încep deciziile asupra structurii high-level a aplicaţiei. Nu putem automatiza un build, un deploy și un ciclu de test fără o idee asupra întregii structuri. Detaliile nu sunt necesare. Avem nevoie doar de o imagine de ansamblu asupra componentelor majore necesare pentru primul release planificat, şi comunicarea dintre componente. Regula de bază este că designul trebuie să poată fie desenat în doar câteva minute.

Pentru designul structurii iniţiale avem nevoie de o viziune high-level a cerinţelor clientului, atât funcţionale cât şi non-funcţionale pentru a ne ghida deciziile.

Figura de mai jos arată cum procesul de TDD se integrează în acest context:

Nu există tot timpul luxul de a crea un nou sistem de la zero. Majoritatea proiectelor pe care lucrăm au început de la un sistem existent care trebuie extins, adaptat sau înlocuit. În aceste cazuri, nu putem începe sa construim un "walking skeleton"; trebuie să ne adaptăm la cel existent, indiferent cât de ostilă ar fi structura lui.

Procesul de începere al TDD-ului pe un asemenea sistem nu e prea diferit faţă de aplicarea lui pe un nou sistem - deşi se poate dovedi mult mai dificil din cauza bagajului tehnic pe care sistemul deja il cară.

Este destul de riscant să lucrezi pe un sistem atunci când nu există teste care să detecteze regresiile. Cel mai sigur mod de a începe TDD-ul este de a automatiza procesele de build și de deploy, şi de a adăuga teste end-to-end care acoperă acele regiuni de cod ce trebuie schimbate. Cu această protecţie, se poate începe adresarea de probleme interne de calitate cu mai multă încredere, refactorizarea codului şi introducerea de unit teste pe măsură ce se adaugă funcţionalităţi.

Menţinerea ciclului test-driven

Odată început procesul de TDD, trebuie menţinut să ruleze fără probleme. Munca pentru o funcţionalitate nouă începe cu un test de acceptanţă care pică, ceea ce demonstrează că sistemul nu are încă funcţionalitatea pe care o vom implementa.

Acest test se scrie folosind terminologia din domeniul aplicaţiei, nu din tehnologiile care stau la baza ei (ex: baze de date sau servere web). Astfel, ne ajută să înţelegem ce ar trebui să facă sistemul nostru și ne protejează suita de teste de acceptanţă împotriva schimbărilor tehnice ale infrastructurii sistemului.

Scrierea acestor teste înaintea scrierii codului ne ajută să clarificăm ceea ce vrem să obţinem. Testele care pică ne ţin concentraţi în implementarea setului limitat de funcţionalităţi pe care le descriu, crescând şansele noastre de a le livra.

Unit testele sunt importante în realizarea design-ului claselor şi în a ne da încrederea că funcţionează, dar ele nu ne spun nimic dacă funcţionează împreună cu restul sistemului.

De unde începem când trebuie să scriem o nouă clasă sau funcţionalitate? E tentant să începi cu cazurile degenerate sau de eşec pentru că sunt de obicei mai uşoare. Cazurile degenerate nu aduc multă valoare sistemului, şi cel mai important nu ne dau feedback despre validitatea ideilor noastre. Incidental, focusarea pe cazurile de eşec la începutul unei funcţionalităţi este rea pentru moral: dacă ne ocupăm doar de error handling, ne simţim ca și cum nu am realizat nimic.

Cel mai bine e să începem cu cel mai simplu caz de succes. Odată ce acel test merge, avem o mai bună idee a structurii reale a soluţiei, şi putem prioritiza între a ne ocupa de posibilele eşecuri ce le-am observat între timp și alte cazuri de succes.

Vrem ca fiecare test să fie cât de clar posibil o reprezentare a comportamentului sistemului sau a obiectului. Când testul e lizibil, atunci construim infrastructura din spatele testului. Ştim că am implementat destul cod de test atunci când testul pică în modul în care ne aşteptam, cu un mesaj de eroare clar, care descrie ce trebuie făcut. Doar atunci putem începe să scriem codul care va face testul sa treacă.

Întotdeauna trebuie să vedem testul cum eşuează înainte să scriem codul care-l va face să treacă, şi să verificăm mesajul de diagnostificare. Dacă testul eşuează într-un mod în care nu ne-am aşteptat, atunci ştim că ceva n-am înţeles bine sau codul este incomplet, și îl putem repara. Când primim eşecul la care ne aşteptăm, verificăm dacă diagnostificarea este de ajutor. Dacă descrierea nu este clară, cineva va trebui să se chinuie atunci când codul se va strica în viitor. Ajustăm codul de test și rulăm testele din nou până când mesajul de eroare ne ghidează la problema cu codul nostru.

Doar scrierea de multe teste, chiar şi atunci când rezultă în acoperirea mare a codului, nu garantează un codebase cu care se lucrează uşor. Mulţi developeri care adoptă TDD îşi găsesc primele lor teste greu de înţeles când le revizuiesc mai târziu. Această greşeală comună constă în faptul că ei se gândesc să testeze doar metodele obiectului. De exemplu: un test ce se numeşte testBidAccepted() ne spune ceea ce face testul, dar nu pentru ce e folosit. Cel mai bine e atunci când ne focusăm pe funcţionalităţile furnizate de către obiectul testat, poate una dintre ele necesită o colaborare cu un alt obiect. Trebuie să ştim cum să folosim clasa ca să atingem un ţel, nu doar să exersăm toate căile prin codul ei.

Este de foarte mare ajutor să alegem numele testelor pentru a descrie cum se comportă obiectul în scenariul testat.

Când scriem unit teste și teste de integrare, stăm în alertă pentru acele părţi de cod care sunt greu de testat. Când găsim o astfel de funcţionalitate, nu trebuie să ne întrebăm doar cum o testăm, ci și de ce este aşa de greu de testat. De cele mai multe ori, cel mai probabil e faptul că design-ul are nevoie de îmbunătăţiri. Aceaşi structură care face codul greu testabil acum, o va face şi mai greu pe viitor.

Procesul de a scrie testele la început este un valoros avertisment a unei posibile probleme de mentenanţă și poate indica anumite indicii de rezolvare a problemei cât e încă la început.

Concluzii

Test-driven development nu înlocuieşte testarea tradiţională, ci în schimb defineşte o modalitate dovedită de a asigura testare eficientă. Un efect secundar al TDD-ului este că testele rezultate sunt exemple funcţionale de invocare a codului, oferind astfel o specificație a codului scris. Din experienţa noastră, TDD funcţionează incredibil de bine în practică și este ceva ce toţi dezvoltatorii de software ar trebui să adopte.

Referinţe:

Test Driven Development: By Example, Kent Beck, Addison-Wesley Longman, 2002

Growing Object-Oriented Software Guided by Tests, Steve Freeman, Nat Pryce,2009

butunclebob.com

www.agiledata.org

cumulative-hypotheses.org

Sponsori

  • comply advantage
  • ntt data
  • 3PillarGlobal
  • Betfair
  • Telenav
  • Accenture
  • Siemens
  • Bosch
  • FlowTraders
  • MHP
  • Connatix
  • UIPatj
  • MetroSystems
  • Globant
  • Colors in projects

Tudor Trișcă a mai scris