fbpx

Praktická ukážka TDD cyklu s podrobným vysvetlením

V tomto článku vám podrobne vysvetlím jednotlivé kroky požadované pri používaní metodológie Test-Driven Development (TDD).

V predchádzajúcom článku som schválne vynechal mnoho detailov. Chcel som aby mohol slúžiť ako referencia, ku ktorej sa môžete kedykoľvek vrátiť. Dnes sa vrátim ku detailom, ktoré by referenčný text robili zbytočne komplikovaným.

Pripomeňme si, že TDD prebieha v nasledujúcom rytme:

Test-Driven Development cyklus

  1. Napíš nový zlyhávajúci test (Červený test)
  2. Napíš produkčný kód tak aby všetky testy prešli (Zelený test)
  3. Refaktoruj
  4. Opakuj od bodu 1 až kým nie si hotový

1. Červený test

Toto je najdôležitejší krok reprezentujúci TDD.

Treba napísať jeden test na neexistujúcu funkcionalitu. Treba vygenerovať prázdnu implementáciu všetkého, čo je v kóde červené, tak aby ho bolo možné skompilovať a spustiť.

Mal by zlyhať. Len takto vieme, že niečo testuje1. Kent Beck ide ešte ďalej (a mne sa to tiež osvedčilo). Skôr ako zbehne test, skúsi odhadnúť akým spôsobom zlyhá (výnimka, nesprávna návratová hodnota z metódy, …). Ak sa trafí, tak všetko funguje ako má. Ale ak dostane inú chybu alebo test prejde, tak to znamená, že niečomu nerozumie správne. Jedna možnosť je, že testovaná funkcionalita je už hotová. Ale častejšie nie je správny test alebo naše porozumenie produkčnému kódu.

Podrobne sa tomuto venujem v článku Prečo musí byť test červený?

2. Produkčný kód

Tento krok vyzerá jasne — napíše sa produkčný kód a hotovo. Ale nie je to také jednoduché.

Jeden z cleľov, ktorý si kladie TDD je najjednoduchšie možné riešenie. A týmto sa „úvod do TDD” začína komplikovať, a môže vyzerať ako nezmysel. Preto vás poprosím o trochu trpezlivosti a snahu neodmietnuť nasledujúce vysvetlenie, kým sa nad tým nezamyslíte.

TDD očakáva, že namiesto komplikovaného riešenia treba vytvoriť dostatočné riešenie, aby prešli všetky doteraz existujúce testy, ale nič navyše. To čo tým dosiahneme je že kód nebude predizajnovaný alebo nebude riešiť veci, ktoré od neho nik nečaká.

Alternatívny pohľad je, že na každý riadok kódu alebo rozhodnutie niečo v kóde zmeniť musíme mať existujúci test. Takže ak naraz napíšeme do kódu príliš veľa, tak sme buď porušili túto zásadu, alebo sa test snaží testovať príliš veľa. Ak budeme pri programovaní dodržiavať túto zásadu, tak kód bude pribúdať plynule a zaseknutie/zamotanie sa bude zriedkavejšíe.

Radšej to ukážem na príklade. Majme nasledujúci jednoduchý test na zásobník:

@Test
public void newStackShouldBeEmpty() {
	assertTrue(new Stack().isEmpty());
}

Poznámka: Vo svojich reálnych testoch pomenovávam testy trochu inak, ale pre zjednodušenie som zvolil tento spôsob. O tom, ako to robím v skutočnosti a prečo si môžete prečítať v Ako nazývať testy?

TDD, ako ho Kent Beck opísal vo svojej knihe Test-Driven Development: By Example, si žiada aby sme napísali do produkčného kódu toto:

public boolean isEmpty() {
	return true;
}

A je to tu. To ako som mohol bez hanby napísať niečo také trápne a určite aj nesprávne? Nuž, musel som. Takto nejak vyzerá TDD vo svojej najstriktnejšej forme a takto ho treba vysvetliť a pochopiť.

Pre tých, čo mi dávajú ešte pár posledných sekúnd musím dodať, že nie vždy je potrebné robiť takéto malé kroky. Keď vieme kam smerujeme, môžeme spraviť aj väčší krok. Ale nie príliš veľký, lebo naň nemáme testy a je možné že ich ani nedopíšeme. Alebo danú implementáciu ani nebudeme potrebovať.

Je to na každom z nás a tom ako isto sa cíti vo svojom kóde. Ale každému sa už určite stalo, že plný nadšenia „vytvoril niečo veľké”, ktoré potom ladil niekoľko hodín. Keby to robil postupne, možno by sa tým problémom vyhol. Takže schopnosť spomaliť sa môže hodiť každému z nás.

Koniec ospravedlňovania sa, poďme vysvetľovať ďalej. Riešenie — hacknutie návratovej hodnoty — spĺňa presne požiadavky TDD na minimálnu implementáciu. Test je zelený a v komponente nie je nič netestované. Ale nezostane to tak dlho.

Treba pridať ďalší test, ktorý metódu isEmpty otestuje z iného uhla a tam už budeme musieť spraviť lepšiu implementáciu. Kent Beck to nazýva triangulácia. (Správne by tu mal nasledovať krok 4. Refaktoruj, ale momentálne nemáme čo.)

@Test
public void populatedStackShouldNotBeEmpty() {
	Stack tested = new Stack();
	tested.push("an item");
	
	assertFalse(tested.isEmpty());
}

Napísanie testu trvalo okamih, vygenerovanie prázdnej metódy push ešte kratšie.

Test je červený, lebo metóda isEmpty vracia stále true. Poďme to zmeniť:

private boolean pushed;

public void push(String item) {
	pushed = true;
}

public boolean isEmpty() {
	return pushed;
}

Takáto implementácia stačí na to, aby naše 2 testy boli zelené, ale stále to je len hack. Dôležité je to, že testy na zásobník (špecifikácia zásobníka) pribúdajú a vieme, že sú správne. Implementácia v dostatočnej kvalite ich dobehne čoskoro. Kent Beck nazýva tento postup Fake it till you make it. Ale najskôr nasledujúci krok…

3. Refaktoring

Každému čo sa dočítal až sem gratulujem, že dokázal potlačiť pochybnosti o mojom duševnom zdraví ?.

Cieľom tohto kroku je upraviť testy aj produkčný kód, tak aby to dávalo zmysel. Ak ho vynecháme, riskujeme že dosiahneme kopu duplikovaného kódu. Zatiaľ to nie je také zlé, ale keby sme tento krok vynechali, tak čoskoro by sme mali strašný humus. Asi by ste sa pozreli na výsledok a rovno by ste TDD zavrhli.

V komponente nemáme veľa možností na refaktoring, ale v teste áno — duplikovaný kód. Opravíme to presunutím inicializácie do @Before.

private Stack tested;

@Before
public void setUp() {
	tested = new Stack();
}

@Test
public void newStackShouldBeEmpty() {
	assertTrue(tested.isEmpty());
}

@Test
public void populatedStackShouldNotBeEmpty() {
	tested.push("an item");
	
	assertFalse(tested.isEmpty());
}

Samozrejme musíme vyskúšať, či testy prechádzajú. Toto je dôležité najmä po refaktoringu komponentu. Ideálne je, keď sa vyhneme súčasnému refaktoringu testov aj komponentov. Zníži sa pravdepodobnosť zavedenia chyby, ktorú si nevšimneme a testy na ňu neupozornia2.

4. Opakuj od začiatku

Týmto som vysvetlil všetky 3 kroky, ktoré treba pri TDD dodržať. Vyskytnú sa situácie, kedy sa niektorý krok dá preskočiť. Videli sme to po tom, čo prvý test ozelenel, ale nemali sme možnosť refaktorovať. Niekedy sa stane, že sa dá preskočiť implementácia, lebo nový test testuje už správnu implementáciu a je zelený. Vtedy treba zvážiť, či ho nechať alebo vymazať. Častokrát je dobré si ho nechať (chráni pred tou nahackovanou implementáciou). Ale stále treba overiť, či je možné refaktorovať.

Keď sme prešli celým cyklom, vrátime sa na začiatok rytmu a napíšeme ďalší zlyhávajúci test…

Pokračovanie príkladu

Stav, komponentu po prvých dvoch testoch nie je moc presvedčivý. Dôvod je ten, že ešte nič užitočné nerobí (ale robí to s minimálnym potrebným kódom). Aby sa začal podobať na normálny zásobník, musíme pridať viac testov — vykonať viac TDD cyklov.

Metóda pop

Metóda push vyzerá ako vyzerá preto, že sme ju používali len na testovanie jej vplyvu na isEmpty. Na to, aby sme ju mohli poriadne implementovať, potrebujeme pridať vyberanie zo zásobníka — pop. Bez nej nemáme ako otestovať, či sa hodnota zapamätala. A už vôbec nemá zmysel riešiť ukladanie viacerých hodnôt a ich vyberanie v opačnom poradí vkladania.

Je typické, že vyvíjame niekoľko metód naraz a naša pozornosť sa presúva medzi nimi.

@Test
public void popGetsPushedValue() {
	tested.push("val");
	assertEquals("val", tested.pop());
}

Máme červený test.

private String item;

public void push(String item) {
	this.item = item;
}

public String pop() {
	return item;
}

public boolean isEmpty() {
	return this.item != null;
}

Malý krok pre programátora, veľký skok pre zásobník ?. A zelené testy.

Už viete, že táto implementácia je dostatočná na ozelenenie testu. Ale prečo som spravil taký triviálny test a nie väčší, ktorý pretestuje celý push a pop naraz? Toto sú moje dôvody:

  • TDD rytmus sa skladá z malých a krátkych krokov. Čím väčší test, tým bude čas strávený v implementácii komponentu dlhší a ja budem dostávať menej priebežných informácií či som niečo pokazil alebo nie.
  • Dobre napísané testy fungujú ako špecifikácia, dokumentácia a príklad použitia. Všimnite si, ako nazývam testovacie metódy — gramaticky správna veta opisujúca očakávané správanie sa. Na to ale musí byť test jednoduchý. Pár riadkov, ktoré pochopím a overím správnosť za pár sekúnd3.
@Test
public void pushedElementsArePoppedInReverseOrder() {
	tested.push("first");
	tested.push("second");

	assertEquals("second", tested.pop());
	assertEquals("first", tested.pop());
}

Červený test. No konečne môžeme opraviť komponent!

private List<String> items = new ArrayList<>();

public void push(String item) {
	items.add(item);
}

public String pop() {
	return items.remove(items.size() - 1);
}

public void isEmpty() {
	return items.isEmpty();
}

Zásobník vyzerá skoro hotový. Ale ešte mi chýba funkcionalita pri vyberaní z prázdneho zásobníka. Hej, dostanem nejakú výnimku, ale mne sa viac pozdáva iná.

@Test(expected = IllegalStateException.class)
public void poppingFromEmptyStackThrowsException() {
	tested.pop();
}

Aby som neodbiehal od prezentácie konceptov TDD, rozhodol som sa, že nebudem komplikovať test testovaním chybovej správy. V skutočnosti by som ju testoval.

public String pop() {
	if (isEmpty()) {
		throw new IllegalStateException();
	}
	return items.remove(items.size() - 1);
}

Záver

Vysvetlili sme si a ukázali ako prebieha vývoj s použitím TDD. Snažil som sa vysvetliť len podstatné body. Inak by to bolo príliš komplikované a aj ja by som sa v tom zamotal. Je toho viac čo treba ukázať ale to až na budúce.

Predpokladám, že máte rozpačité pocity. Asi málo ľudí nemá, keď vidí TDD po prvýkrát. Ale dúfam, že máte dosť otvorenú myseľ, ste zvedaví čo bude ďalej a ešte sa uvidíme.


  1. V praxi som videl strašne veľa testov, ktoré nič netestovali. Ak aj náhodou boli schopné zlyhať, zvyčajne to bolo spôsobené nečakávanou výnimkou, ktorá s daným testom nijak nesúvisela. Takéto testy sú zvyčajne viac na obtiaž ako užitočné.
  2. Najčastejší prípad potreby zmeny testov a komponentu je zmena parametrov metódy. Ja na to používam skoro výlučne Change Signature refaktoring v IDE. Ak pridávam nový parameter, tak ako default hodnotu občas použijem niečo neskompilovateľné. Takto ma IDE upozorní, kde všade sa mám pozrieť a rozhodnúť o hodnote nového parametra.
  3. Vždy, keď potrebujem zistiť čo robí test, najskôr si prečítam meno metódy a potom zbežne pozriem telo. Šetrí to čas. Ak mám teda šťastie, že test je krátky a s dobrým menom.

1 komentár k “Praktická ukážka TDD cyklu s podrobným vysvetlením”

Nie je možné pridávať komentáre.