8. týždeň

Tvorba jednotkových testov

Základy testovania pomocou jednotkových testov, ich význam a použitie techniky Test Driven Development.

Ciele

  1. Pochopiť význam automatizovaného testovania a oboznámiť sa so základnými pojmami v oblasti unit testov.
  2. Naučiť sa písať jednoduché unit testy.
  3. Oboznámiť sa s princípmi „equivalence partitioning“ a „boundary testing“.
  4. Naučiť sa písať testy pokrývajúce riadky kódu a vetvy.
  5. Naučiť sa vytvárať testy súčasne s testovaným kódom v súlade s technikou Test Driven Development.

Úvod

Úvod do vytvárania jednotkových testov

Postup

Krok 1: Význam automatizovaného testovania

  • Hlavným cieľom testov je predísť vzniku chýb v programe. Pomocou testov overujeme, či sa program správa podľa našich očakávaní. Vďaka tomu, že sa testy môžu vykonávať automaticky, vieme nájsť chyby, ktoré by sme letmým používaním programu nenašli.
  • Testy predstavujú špecifikáciu, ktorá je zapísaná v programovacom jazyku, a je teda vykonateľná. To znamená, že je možné automaticky vyhodnotiť, či program zodpovedá alebo nezodpovedá špecifikácii. (Samozrejme, nie je možné zachytiť v nej úplne všetky aspekty softvéru.)
  • Zmeny v zdrojovom kóde môžeme robiť s väčšou istotou, že program aj naďalej funguje správne. Vďaka tomu môžeme byť oveľa flexibilnejší pri zlepšovaní štruktúry kódu a jeho ďalšom vývoji.
  • Testy tiež môžeme chápať ako istú formu dokumentácie. Test často obsahuje jednoduché príklady konkrétnych vstupov a zodpovedajúcich výstupov. Tie môžu byť jednoduchšie na pochopenie ako zdĺhavý textový popis alebo kód testovanej funkcie.

Automatizované testy sú dnes nevyhnutnou súčasťou vývoja softvéru. Aby sa zabezpečila vysoká miera otestovania programu, je potrebné testy vyvíjať súčasne s kódom programu, k čomu vedie technika Test Driven Development.

Krok 2: Základné pojmy

Automatizovane môžeme testovať systém ako celok, jeho väčšie súčasti, alebo každú jednotku osobitne. Na tomto cvičení sa oboznámime práve s testami jednotiek („unit tests“). Každý test testuje práve jednu „jednotku“ (unit). Čo táto jednotka znamená, záleží od konkrétneho prípadu: môže sa jednať o funkciu/metódu, sadu príbuzných funkcií/metód, modul, triedu, či sadu príbuzných tried.

Pre zjednodušenie vytvárania jednotkových testov sa väčšinou používajú špecializované knižnice alebo rámce (framework). My budeme používať veľmi jednoduchú knižnicu greatest, ktorá poskytuje základné prostriedky pre definovanie testov, ich organizáciu a spúšťanie.

Keďže jednotiek je v systéme veľa, aj testov býva väčšie množstvo a je potrebné nejako ich usporiadať. V našom prípade bude jednotkou (unit) funkcia. Knižnica greatest umožňuje definovať viacero súprav testov (test suite). Každá súprava býva uložená v samostatnom súbore a obsahuje niekoľko testov, pričom jeden test je reprezentovaný jednou funkciou.

Poznámka

Iné knižnice môžu používať mierne odlišnú organizáciu. Napr. JUnit pre jazyk Java označuje jeden súbor (triedu) ako test case. Tie sa potom môžu voliteľne zhlukovať do súprav (test suite).

Úlohou testu je overiť, či testovaná jednotka pri daných vstupoch dáva správny výstup. Na to používa tzv. tvrdenia (assertions). Ak niektoré z tvrdení nie je pravdivé, daný test je neúspešný (FAIL). V opačnom prípade je úspešný (PASS).

Krok 3: Implementácia jednotkových testov

Úloha 3.1

Stiahnite si program Notwork a oboznámte sa s ním.

Na predvedenie tvorby unit testov budeme používať zdrojový kód programu Notwork. Stiahnite si zdrojový kód pomocou git-u (nepotrebujete nastavovať kľúče, ide o anonymný prístup na čítanie):

$ git clone https://git.kpi.fei.tuke.sk/zsi-labs/notwork.git

Otvorte stiahnutý projekt, oboznámte sa so zdrojovými súbormi a skúste program spustiť a používať.

Notwork je jednoduchá sociálna sieť s konzolovým rozhraním. Má tú výhodu, že funguje aj offline. Nevýhodou je, že podporuje len jedného používateľa, príspevky si teda budete musieť "lajkovať" sami. Na rozdiel od bežných sociálnych sietí, jeden príspevok môže mať aj viacero "lajkov" od jedného používateľa.

Poznámka

Na preloženie programu potrebujete mať nainštalovaný GNU Make a GCC. Inštalácia týchto nástrojov bola opísaná v prvom cvičení.

Úloha 3.2

Oboznámte sa s existujúcimi testami.

Testy sú implementované ako funkcie, ktorých definícia sa začína makrom TEST. Vo vnútri testu sa overí jedno alebo viac tvrdení (assertion) pomocou makra ASSERT, alebo niektorého z ďalších pomocných makier a test sa ukončí makrom PASS(). Napríklad takýto test sa nachádza v súbore test_wall.c:

TEST empty_wall_should_have_no_posts() {
    wall_t *wall = create_wall();
 
    ASSERT_EQ(NULL, get_post(wall, 0));
    ASSERT_EQ(NULL, get_post(wall, 1));
 
    destroy_wall(wall);
    PASS();
}

Môžete si všimnúť, že väčšina testov má rovnakú štruktúru — sú zložené zo 4 častí:

  1. príprava údajov (v príklade je to vytvorenie nástenky),
  2. vykonanie testovanej operácie nad údajmi (volanie get_post),
  3. kontrola, či operácia má očakávaný výsledok (ASSERT_EQ),
  4. upratanie (uvoľnenie pamäte).

Niekedy sú prvé tri fázy označované ako Given-When-Then.

Úloha 3.3

Spustite testy projektu Notwork.

Na spustenie testov môžete použiť pripravený cieľ test v priloženom súbore Makefile:

$ make test

Krok 4: Tvorba jednoduchého unit testu

Úloha 4.1

V súprave (test suite) s názvom test_post vytvorte test pre funkciu like_count_text zo súboru post.c.

Funkcia like_count_text, ktorú budeme testovať, vráti text reprezentujúci počet "like-ov" daného príspevku, pričom rozlišuje medzi jednotným ("1 like") a množným číslom ("0 likes", "2 likes", "3 likes", ...). Test môže byť nasledovný:

TEST like_count_text_should_use_correct_plurals() {
    char buf[LIKE_COUNT_BUFFER_SIZE];
    post_t *post = create_post("new post");
 
    ASSERT_STR_EQ("0 likes", like_count_text(post, buf));
 
    like_post(post);
    ASSERT_STR_EQ("1 like", like_count_text(post, buf));
 
    destroy_post(post);
    PASS();
}

V tomto unit teste sme vytvorili nový príspevok a porovnali sme skutočný výstup funkcie like_count_text s očakávaným výstupom. Následne sme zavolaním funkcie like_post zvýšili počet "lajkov" príspevku na 1 a opäť sme overili zhodu výstupu testovanej funkcie s očakávaným reťazcom.

Poznámka

Nezabudnite pridať nový test do zoznamu testov súpravy:

SUITE(test_post) {
    RUN_TEST(like_post_increments_like_count);
    RUN_TEST(like_count_text_should_use_correct_plurals);
}

Úloha 4.2

Doplňte do testu overenie pre prípad, že príspevok má 2 "lajky".

Krok 5: Význam unit testov pri refaktorizácii

Refaktorizácia (refactoring) je zmena zdrojového kódu bez zmeny správania výsledného programu. Jej cieľom je dosiahnuť prehľadnejší, kratší a ľahšie udržiavateľný kód, zníženie využitia zdrojov (čas, pamäť) a pod. Refaktorizácia je neoddeliteľnou súčasťou vývoja softvéru. Ako však zabezpečiť, že zmeny, ktoré programátor vykoná, naozaj nebudú mať vplyv na správanie programu a nezanesú do neho chyby?

Úloha 5.1

Refaktorizujte funkciu like_count_text v súbore post.c.

Hoci funkcia navonok funguje správne, trpí viacerými nedostatkami: Používa nevhodné názvy premenných, je zbytočne zložitá a neefektívna, a obsahuje nepoužitú premennú. Povedzme, že funkciu like_count_text refaktorizujeme nasledovne:

char *like_count_text(post_t *post, char *buf) {
    if (post->like_count == 1) {
        strcpy(buf, " 1 like");
    } else {
        sprintf(buf, "%d likes", post->like_count);
    }
    return buf;
}

Upravte funkciu vo vašom projekte tak, že doslovne skopírujete horeuvedenú implementáciu. Na prvý pohľad môže vyzerať v poriadku. Skúste však spustiť testy. Výsledkom bude zlyhanie. Zlyhaný unit test nás takpovediac upozornil, že refaktorizácia neprebehla správne.

Úloha 5.2

Opravte refaktorizovanú funkciu like_count_text tak, aby jej správanie zodpovedalo unit testu.

Krok 6: Testované hodnoty

Ideálne unit testy by otestovali všetky možné kombinácie vstupov každej jednotky. To však prakticky nie je možné. Cieľom testera je napísať testy tak, aby bola čo najväčšia pravdepodobnosť odhalenia chyby.

Funkcie často mávajú pri určitých množinách vstupov podobné správanie. Povedzme, že máme funkciu bool zapocet_udeleny(int body) a za zápočet je 40 bodov. Ak má študent 0-20 bodov, zápočet nedostane. Pokiaľ ich má 21-40, zápočet dostane. Týmto množinám hovoríme triedy ekvivalencie (equivalence classes , equivalence partitions). Mali by sme teda otestovať jeden vstup z množiny "neudelený zápočet" (napr. 10) a jeden z množiny "udelený" (napr. 30).

V skutočnosti existujú v našom príklade ďalšie dve triedy ekvivalencie, a to neplatné (invalid): počet bodov pod povoleným rozsahom (< 0) a počet bodov nad povoleným rozsahom (> 40). Mali by sme teda zisťovať, či funkcia pri vstupoch ako -10 a 50 vráti požadovanú chybovú konštantu.

V praxi často vznikajú tzv. off-by-one chyby, kde sa počet alebo index od správnej hodnoty líši o 1. Preto je dobré namiesto ľubovoľnej hodnoty z danej triedy ekvivalencie (alebo spoločne s ňou) testovať hraničné hodnoty (boundary values). V našom prípade by sme teda testovali hodnoty -1, 0, 20, 21, 40 a 41:

Triedy ekvivalencie bodov za zápočet a ich hraničné hodnoty
Obr. 1: Triedy ekvivalencie bodov za zápočet a ich hraničné hodnoty

Úloha 6.1

V projekte Notwork preskúmajte súpravu testov test_wall, ktorá obsahuje niekoľko testov pre funkciu get_post zo súboru wall.c. Ktoré triedy ekvivalencie funkcie get_post sú pokryté testami a ktoré nie?

Funkcia get_post vráti príspevok s daným ID, pričom číslovanie začína 1-kou. Pre testovanie pridajte na nástenku ("wall") napr. 4 príspevky. Vstupom funkcie je ID, pričom možné hodnoty môžeme rozdeliť na jednu platnú triedu ekvivalencie a dve neplatné.

Úloha 6.2

Doplňte ďalšie testy do súpravy test_wall, ktoré overia hodnoty z doteraz neoverených tried ekvivalencie.

Krok 7: Pokrytie riadkov kódu a vetiev

Funkcie obsahujú podmienky a cykly. To má za následok, že pri každom volaní sa nemusia vykonať vždy všetky riadky kódu. Isté časti (často práve tie chybné) tak môžu ostať nedotknuté. Jednou z úloh testerov je dosiahnuť, aby sa v rámci testov vykonala čo najväčšia časť riadkov zdrojového kódu, a tiež aby vykonávanie programu prešlo všetkými vetvami. Percento riadkov kódu, ktoré bolo vykonané počas testovania, sa zvykne označovať line coverage alebo „pokrytie riadkov kódu“. Miera otestovania vetiev sa zas označuje — „pokrytie vetiev“ alebo branch coverage.

Úloha 7.1

Pridajte do súpravy test_post testy funkcie create_post.

Funkcia create_post vytvorí nový príspevok s daným textom a žiadnym "lajkom". Takto by mohli vyzerať jej jednoduché testy:

TEST created_post_should_have_correct_text() {
    char *text = "my testing post";
    post_t *post = create_post(text);
 
    ASSERT_STR_EQ(text, post->text);
 
    destroy_post(post);
    PASS();
}
 
TEST created_post_should_have_zero_likes() {
    post_t *post = create_post("test");
 
    ASSERT_EQ(0, post->like_count);
 
    destroy_post(post);
    PASS();
}

Hoci testy prejdu (pass), netestujú funkciu dostatočne, lebo neprejdú všetky vetvy v kóde funkcie create_post.

Úloha 7.2

Doplňte ďalší test tak, aby ste dosiahli 100%-né pokrytie riadkov funkcie create_post.

Poznámka

Samozrejme, že v praxi žiadny človek neprechádza kód riadok po riadku a nepočíta, koľko z nich bolo vykonaných. Na tento účel existujú nástroje - tzv. code coverage tools. Pre jazyk C je to napríklad program gcov.

Krok 8: Vývoj riadený testami — Test Driven Development (TDD)

Technika vývoja riadeného testami sa snaží o zlepšenie kvality softvéru tým, že vyžaduje vytváranie testov súčasne alebo dokonca ešte pred vytváraním ostatného kódu. Jej princíp spočíva v tom, že vývoj softvéru prebieha pomocou opakovania troch krokov:

  1. Napísanie testu pre časť funkcionality, ktorá sa má implementovať (červená fáza — testy sú neúspešné).
  2. Písanie funkčného kódu potrebného na úspech testov (zelená fáza).
  3. Refaktorizácia kódu pre zlepšenie jeho štruktúry.

Úloha 8.1

Implementujte samostatné počítanie a vypisovanie „dislajkov“. Vytvorte implementáciu technikou TDD.

V súčasnej verzii „dislajk“ len zníži počet „lajkov“ príspevku. Našim cieľom je však počítať „dislajky“ samostatne a zobrazovať ich počet. Počet „dislajkov“ sa bude skloňovať podobne ako počet „lajkov“ s tým rozdielom, že pri hodnote 0 sa nebude zobrazovať vôbec.

Pre implementáciu počítania „dislajkov“ potrebujeme upraviť funkcie create_post, dislike_post, like_count_text a údajovú štruktúru post_t. Začať teda môžete implementáciou testu created_post_should_have_zero_dislikes. Keď tento test bude úspešný, môžete pokračovať napríklad testami

  • dislike_post_should_increment_dislikes
  • given_dislikes_are_present_like_count_text_should_include_them

Krok 9: Domáca úloha

Úloha 9.1

Doplňujúce zdroje

  1. Unit testy - blog M. Fowlera
  2. Test Case Design
  3. Dokumentácia knižnice greatest