În acest articol, voi încerca să dezvolt conceptul de Behavior Driven Development (BDD) folosind framework-ul de testare din JavaScript, Jasmine. Cum mulți dintre noi cunoaștem JavaScript ca un limbaj care nu mai este unul de scripting, deseori se întâmplă să avem o migrare, poate nedorită, a logicii de business de pe partea de server pe cea de client.
În acest moment, partea de client a aplicației noastre crește în complexitate, drept urmare va avea mai multe responsabilități. Este important de subliniat că odată cu creșterea numărului de responsabilitați cresc și costurile de mentenanță ale unui proiect. S-a demonstrat stiințific că partea de mentenanța a unui proiect este de aproximativ 75%, iar partea de dezvoltare este de doar 25% [1]. Drept urmare, pe lângă factorii de performanță și scalabilitate ai aplicației noastre se numără și procesul de mentenanță. În acest context, metologia BDD ne ajută să construim un sistem decuplat, matur și care se poate "adapta" ușor la modificările din viitor.
În practică, dacă întrebăm zece programatori ce înseamnă BDD, sunt mari șanse să primim zece răspunsuri diferite. Unii spun că BDD este doar Test Driven Development (TDD) implementat bine, alții spun că BDD este a doua generație a metodologiei Agile, iar alții afirmă ca BDD este o tehnică elegantă de exprimare a specificațiilor executabile, și lista ar putea continua.
Pentru că BDD vine ca o augmentare peste TDD, trebuie să descriem în câteva cuvinte metodologia TDD. Programatorii care fac TDD au ajuns la concluzia că singurul lucru în comun ce are TDD-ul cu testele, este cuvantul "test" și nimic mai mult. La început, am putea recunoaște că sună puțin ciudat, dar voi încerca să fac mai expresivă aceasta neîntelegere.
Pașii elementari prin care fiecare programator trece, atunci când face TDD sunt următorii:
La acest punct 4 vom oferi explicații.
Din nefericire, majoritatea programatorilor nu reușesc să treacă de pasul al doilea.
Dan North, fondatorul metodologiei BDD, descrie BDD-ul ca pe o tehnică de implementare a unei aplicații, prin descrierea comportamentului din perspectiva clientului, numit de acum înainte stakeholder.
Această descriere, o vom diviza pe mai multe secțiuni, în cele ce urmează.
Din perspectiva programatorului, cred că fiecare din noi ne-am lovit de-a lungul timpului de dezavantajele conceptului "push-based". Pe scurt, conceptul "push-based" descrie acele timpuri "primitive", când managerul alocă task-uri, menționând în cele din urmă: "vreau ca task-ul ăsta să fie implementat până la sfârșitul săptămânii". În acest fel, programatorul este constrâns să folosească tehnologii si limbaje, altele decât cele pe care dorește să le urmărească.
BDD închide această fereastră prin expunerea unui "backlog". Ne putem gândi la un "backlog" ca la un queue( coadă de mesaje ). Se bazează pe același principiu ca binecunoscutul design pattern Producer Consumer, unde fiecare programator joacă rolul unui consumer, iar stakeholder-ul rolul producer-ului, unde cel din urmă pune requirements-urile în acest queue. Această abordare întărește comunicarea dintre programator și stakeholder, pentru că fiecare cerința, este un high-level feature, expus din perspectiva stakeholder-ului care nu este interesat de procesul intern al realizării tehnice a funcționalității (sigur există situații în care tehnicul intră în discuție). După aceasta programatorul împarte aceste cerințe în task-uri, care sunt apoi prioritizate în funcție de valoarea lor de business. Această abordare întărește siguranța că stakeholder-ul va ajunge să primească ceea ce a solicitat în cerința inițială, lăsând în același timp libertatea programatorului să aleagă soluțiile tehnice cele mai potrivite pentru problema expusă.
După cum știm, abordarea test-first-code impune de la bun început o gândire axată pe ce anume trebuie îndeplinit pentru a face testul să treacă. Începând cu un test, vom scrie doar cod pentru a face acest test să treacă și nimic mai mult. În acest context TDD-ul evită Big Design Up Front (BDUF). Cel care scrie testul este și primul utilizator al API-ului. După cum Kent Beck explică în binecunoscuta sa carte, "TDD By Example" [3], programatorul trebuie să se transpună în cel care va utiliza API-ul - clientul final al serviciului- și apoi va scrie testul.
Din nefericire, scriind codul de producție și apoi testele, în cele mai multe cazuri, se creează greutăți sau chiar imposibilitatea de a scrie mai târziu testele pentru acest cod. De ce? Pentru că designul sistemului nu a fost inițial conceput a fi testabil. Urmând această cale se ajunge la un cod fragil, "tightly coupled", care are multe depedențe și care nu poate fi reutilizat. Finalmente un cod pe care noi, programatorii, l-am numi "unreliable".
De câte ori nu ni se întâmplă că schimbând ceva într-o parte, așa-zis, izolată de cod, se ajunge să se impacteze o componentă dependentă?
În fond testele trebuie considerate o plasă de siguranță sub orice refactorizare și orice posibilă schimbare în logica aplicației. Este important de amintit că trebuie testată logica aplicației, comportamentul ei, și nu doar chestiuni triviale, cum ar fi getter-i/setter-i, sau mai rău, API-uri de la terțe părti, care au propriile lor teste. În special, în lumea Agile unde testarea exhaustivă a produsului in fiecare iterație este practic imposibilă, este nevoie încă o dată de o suită de teste foarte bine pusă la punct care să reducă riscul viitoarelor regresii.
Diferența dintre TDD și BDD este că TDD-ul induce deseori starea de confuzie dată de un test, pe când BDD-ul se focusează pe o caracteristică fundamentală a codului și anume comportamentul.
Un test va verifica dacă o condiție este îndeplinită sau nu, în timp ce un comportament descrie printr-o reprezentare mai expresivă logica sistemului. În BDD testul ia forma unei specificații. Finalmente atât TDD-ul cât și BDD-ul furnizează specificații executabile care servesc ca documentație vie - "living documentation".
Să insistăm puțin asupra sintagmei living documentation. De cele mai multe ori avem specificațiile date într-o formă statică, fie sub forma unui document, fie sub forma de story-uri. Folosim cuvântul static aici pentru a puncta antiteza acestui gen de specificații vizavi de niște specificații dinamice date de o abordare BDD. Așadar, acest gen de specificații statice, combinate cu specificații executabile, ajung să încalce principiul Dont Repeat Yourself (DRY), fiindcă există o duplicare a informației. Când cerințele aplicației se schimbă, trebuie să adaptăm această schimbare și să o transpunem în implementarea reală a sistemului. De cele mai multe ori specificația statică va rămâne "în urmă", pierzându-și în cele din urmă valoarea. Așadar atât documentația statică cât și cea dinamică, reprezentată de specificațiile executabile, trebuie sincronizate într-un fel, lucru care finalmente introduce o povară pentru fiecare programator. BDD-ul promovează ideea de a păstra detaliile tehnice în specificațiile executabile, astfel prin rularea lor putem dovedi că cerința din implementare este finalizată. Așadar vom ști cu exactitate când am terminat.
Acest tip de specificații dinamice trăiesc alături de codul nostru de producție.
Dacă există o formă de development numită Outside In, intuiția ne spune că ar trebui să existe și o formă numită Inside Out(sau bottom-up), pe care cred, cu toții am urmat-o la un moment dat. Să începem cu cea din urmă. Urmând o abordare Inside Out începem implementarea pe baza unor operații sau funcții, pe care le considerăm ca "main-stream" în cadrul funcționalității.
Este foarte ușor să realizăm prematur abstracții, construcții, care sunt pur și simplu greșite de la bun început.
Inside Out ne obligă să ne gândim de la început la toate ramificațiile, toate dependențele între componente, făcând procesul de dezvoltare complicat. Pe măsură ce logica de business devine mai complexă, va fi din ce în ce mai dificil să găsim calea corectă în implementarea funcționalității.
Deseori această formă de dezvoltare ne forțează să scriem cod care mai târziu nu este reutilizabil și care devine repede îmbătrânit. Aceasta duce la folosirea ineficientă a timpului și a costurilor de proiect. În sensul pur al Object Oriented Design-ului (OOD) putem spune că violăm un principiu fundamental din lumea Xtreme Programming (XP), si anume YAGNI ( You Ain"t Gonna Need It) [4]
În Outside In începem prin a scrie o documentație high-level, care are o valoare de business. Începând cu aceste funcționalități high-level "we code our way in", prin divizarea specificațiilor în entități coezive, așadar implementăm doar ceea ce este parte a cerinței inițiale și nimic mai mult.
Scriind cod în această formă, top-bottom, suntem forțați să ne comportăm ca și când entitățile dependente ar exista deja. Aceasta poate fi percepută ca un dezavantaj, fiindcă nu vom putea să rulăm specificațiile până când întreaga funcționalitate este implementată. Astfel se contrazice regula BDD-ului( sau TDD-ului ) de a rula specificațiile cât mai des posibil, pentru a surprinde cât mai repede eventualele probleme.
În același context, implementând într-o manieră Outside-In, pornind de la o specificație high-level, nu putem afirma că aceasta este chiar un "baby-step". Scopul este să derivăm specificații low-level din cele high-level. Principiul care stă la baza acestui concept este "divide et impera" [5]. Este mult mai ușor să rezolvăm o problemă complexă, dacă găsim o soluție prin care să spargem problema mare într-un set de probleme mai specifice, într-un context mai redus, care în final pot fi agregate în scopul rezolvării problemei inițiale.
O altă variantă de a gândi într-o manieră BDD, este relaționarea "One-To-Many". Elementul "One" este subiectul specificațiilor/testelor noastre( Subject Under Test - SUT ), iar entitățile "Many" sunt dependențele cu care SUT interacționează. Este o idee bună să amânăm orice implementare concretă a dependențelor pe mai târziu, timp pe care-l putem folosi să deprindem o mai bună cunoaștere a domeniului și a problemei pe care vrem să o rezolvăm. În acest mod, SUT se va baza pe "test-doubles" și nu pe o implementare concretă a dependențelor. În același context bazându-ne, spre exemplu, pe abstracții, și nu pe entitați concrete, vom reuși să integrăm un design "loosely-coupled" între SUT și colaboratorii lui.
Putem adopta mai multe soluții de organizare a specificațiilor, însă cele mai cunoscute pattern-uri de structurare a specificațiilor, permit gruparea bazată pe:
În cele ce urmează le voi descrie punctele forte ale fiecărei modalități de structurare.
Voi începe cu gruparea bazată pe "Feature" care permite organizarea codului în specificații, în funcție de subiectul cerinței inițiale. Putem lua cazul practic în care domeniul logic este un magazin online. Cerința este de a calcula totalul produselor din coșul de cumpărături. În logica acestei cerințe vom avea câteva rutine care comunică între ele prin diverse mesaje. De exemplu:
var shoppingCart = getShoppingCart(user)
var totalAmount = ItemCalculator.calculateTotal(shoppingCart)
var totalAmountWithVAT = Taxes.applyVAT(totalAmount)
Aceste operații pot fi structurate ca un singur feature. Partea bună este că acest mod de structurare, permite izolarea fiecărui feature în parte, astfel încât putem avea o suită de specificații pentru fiecare feature în parte și putem înțelege funcționalitatea unui feature doar rulând aceste specificații. Dezavantajul acestei metode de organizare este că, în timp, cerințele se schimbă, noi funcționalități augmentează feature-ul inițial, ca urmare specificația se poate degrada într-un timp foarte scurt datorită numărului mare de modificări. Scopul final este să avem o suită de specificații care exprimă într-un mod clar cerințele testate din requirement-ul inițial, astfel încât ele să servească ca "living documentation".
Calitatea codului nostru din specificații trebuie să fie tranzitiv egală cu codul nostru de producție.
Structurarea specificațiilor bazată pe "Fixture" permite o mai bună aranjare, datorită posibilitații de a avea mai multe contexte de execuție. Luând cazul teoretic al unei operații online, putem avea un context de execuție atunci când coșul de cumpărături este gol sau coșul de cumpărături a ajuns la suma maximă admisă. Această abordare de structurare a specificațiilor duce la un design elegant și curat, având suita noastră de operații grupate pe baza contextului în care ele sunt executate. În scurt timp, vom avea parte și de un exemplu practic, în care voi descrie cum putem ajunge la o astfel de structurare a codului pe mai multe contexte de execuție. Exemplul practic este o mică librarie, un EventBus, care ajută la o mai bună decuplare a obiectelor comunicante.
Ultimul mod de structurare este bazat pe gruparea fiecărei operații în parte, adică "by Method". Poate fi un proces incomod, luând cazul practic în care avem definite două metode foo() și bar(). Cum, probabil, suntem "test-infected", avem o suită de specificații scrise atât pentru foo(), cât și pentru bar(). Acum, operația foo() se poate folosi de operația bar(), dar noi avem deja scrise testele pentru bar(), astfel că testele pentru foo() vor acoperi și cazurile operației bar(). Când introducem o redundanță în specificațiile noastre între componentele testate, acestea vor deveni greu de înțeles, fiind totodată redundante. Dacă o singură problemă va apărea în testele noastre, cum ar fi cazul, mult întâlnit, în care un test se comportă într-un mod non-deterministic, acea problemă va virusa toată suita noastră de teste, făcându-le "unreliable". Capacitatea de abstractizare face ca orice programator care vede o problemă într-unul din teste, să asocieze toată suita de teste cu acel test "infectat".
BDD construiește pe un limbaj business, denumit GHERKIN [6]. Acesta este un simplu Domain Specific Language (DSL). Acest DSL și-a făcut prima dată apariția în cadrul unui alt framework de testare denumit Cucumber [7]. Spunem că GHERKIN este un limbaj business, pentru că nu se adresează în totalitate programatorului, ci poate fi ușor interpretat și înțeles și de un non-tehnic.
După cum bine știm TDD are propriul pattern de organizare a codului din corpul testelor, denumit Arrange Act Assert (AAA). Aceste instrucțiuni ne ajută să structurăm codul într-un test, într-o manieră mentenabilă și ușor de citit. Din nefericire, simpla structurare a codului, nu va ajuta un expert în domeniu să înțeleagă codul nostru.
Pe de altă parte GHERKIN augmentează această comunicare dintre programator și stakeholder. Un limbaj ubicuu se naște între cele două lumi.
Cele trei instrucțiuni iau acum forma:
În acest context, putem identifica foarte ușor o cerință care poate fi interpretată de un non-programator: "GIVEN the employees from a department, WHEN payday comes and computeSalary() is invoked, THEN i want to receive a salary report for all employees".
În această manieră, cerința este foarte ușor de interpretat. Această formă întărește expresivitatea folosind un limbaj natural pentru a declara o cerință reală.
Înainte de a termina prima parte a acestui articol, vreau să mai amintesc câteva dintre avantajele BDD față de tradiționalul TDD:
A doua parte a acestui articol are o amprentă practică, având ca principal actor framework-ul Jasmine [8].
Scopul inițial a fost să construiesc o simplă librărie urmărind metodologia BDD, dar articolul a reușit să înghită câteva pagini bune până acum, așa că o să descriu câteva părți importante și las cititorului ocazia de a inspecta codul pe github [9].
În câteva cuvinte, Jasmine este un framework de testare unitară și nu un "integration-framework", după cum alții presupun. Permite structurarea specificțtiilor bazat pe "fixture", permițând a avea blocuri "describe" imbricate. Totodată vine cu câteva obișnuințe comune pentru a configura contextul de execuție al specificațiilor( setup & tearDown ). Ca implementare concretă a patternului Test Doubles folosește Spies( Spioni ).
O simplă utilizare poate fi exprimată în următorul mod:
describe("EventBus - SUT", function () {
// various fixtures
it("should do this and that - describe behavior", function() {
// some executions
expect(thisResult).toBeTruthy();
});
});
În contextul de față, SUT-ul nostru este EventBus. După cum sugerează și numele, este un data-bus, responsabil de managementul event-urilor și al listener-ilor.
O abordare din punc de vedere al beneficiilor designului bazat pe un Event Bus, ar conduce către un design "loosely-coupled" între obiectele comunicante. Strămoșul său este binecunoscutul Observer Design Pattern. Pentru a evita o strânsă legătură între obiectele comunicante, delegăm această responsabilitate Event Bus-ului, entitate care știe de fiecare event înregistrat și de fiecare listener. Logica decuplată aici este că nici un event nu va ști nimic despre cel care va trata, onora acel request, adică despre listener. Fiecare event este înregistrat în acest EventBus, așteptând să fie tratat de un listener.
După cum bine știm jQuery este o librărie care simplifică mult operațiile ( bazate pe evenimente ) la nivel de DOM. În acest context, EventBus-ul prezentat, ne lasă să manevrăm evenimentele axate pe comportamente la nivel de aplicație.
O utilizare practică a EventBus-ului o poate avea binecunoscutul, până acum, scenariu al unui magazin online. Când utilizatorul apasă pe butonul de "buy", deseori am vrea ca o suită de operații să fie cascadate, astfel încât produsul cumpărat să fie adăugat în coșul nostru de cumpărături, o fereastră, posibil modală, de înștiințare să fie afișată, iar produsul cumpărat am vrea să dispară din lista de produse sugerate.
În exemplul de față, vom înregistra câteva evenimente în Event Bus, după care vom subscrie un singur listener, care va trata doar un singur tip de eveniment.
describe("EventBus", function () {
var openConnectionEvent, sendStreamEvent;
beforeEach(function () {
openConnectionEvent = "openConnectionEvent";
sendStreamEvent = "sendStreamEvent";
);
describe("having a collection of event/listeners - fixture", function () {
beforeEach(function () {
EventBus.registerEvent(sendStreamEvent, openConnectionListener);
EventBus.registerEvent(sendStreamEvent, sendStreamListener);
});
afterEach(function () {
EventBus.cleanBus();
});
describe("#fireEvents - operation", function () {
it("should trigger all registered listeners to handle the operation",
function () {
spyOn(console, "log").andCallThrough();
EventBus.fireEvent(sendStreamEvent);
expect(console.log).toHaveBeenCalled();
// ... other related expectations
});
});
});
}) ;
Pe scurt, Jasmine permite o bună structurare a specificațiilor, astfel încât putem urmări foarte ușor firul comportamental al execuției doar citind descrierea din cadrul blocurilor "describe".
Jasmine oferă out-of-the-box mecanismul de "setup" și "teardown" pentru o mai bună organizare a codului din cadrul specificațiilor, "spies" ca implementare concretă a pattern-ului "Test Doubles". Pe scurt "spies" ne permit să inspectăm ce rutine au fost invocate și opțional, care sunt parametrii cu care au fost invocate aceste rutine. În același context, putem instrui anumite apeluri de rutine să returneze rezultate specifice cerințelor noastre și putem opta pentru o invocare reală a rutinei folosind un ,,spy"și nu un "fake-call".
Integrarea specificațiilor în contextul de Continuous Integration (CI) este relativ trivială.
BDD vine ca o augmentare peste tradiționalul TDD. Ca și metodologia TDD, BDD induce designul aplicației către o arhitectură decuplată. Designul incremental face parte din acest proces și este întâlnit în faza de refactorizare. Orice specificație high-level trebuie divizată în mai multe entități coezive care în final ne ajută să implementăm funcționalitatea dorită. Fiecare cerință este prioritizată în funcție de valoarea de business. Cu acest arsenal de tehnici putem foarte ușor să evităm să implementăm un BDUF prematur.
Forma de dezvoltare software Outside In este motorul care angrenează conceptul BDD. Dintr-un punct de vedere, această formă, ne constrânge, neputând să rulăm toate testele până în momentul în care avem o implementare concretă a întregii cerințe. Unele framework-uri asigură mecanismul de "cross-this-specification", care ne permite să evităm rularea unei specificații high-level, sau altfel spus un test de acceptanță. Pe de altă parte Outside In, ne încurajează să gândim în abstracții și să evităm o implementare reală, care de multe ori se dovedește a fi prematură, până când deprindem destulă cunoaștere a domeniului/problemei.
Partea și mai bună este că putem face BDD într-un limbaj dinamic ca și JavaScript.
"Probably if we do TDD well, we are already doing BDD".