Poglavlje 5: Jedinično testiranje

Priča za početak. U jesen 1997. godine dva programera sjela su u avion od Ciriha do Atlante, na putu na veliku konferenciju o objektnom programiranju OOPSLA. Jedan je bio Kent Beck, Amerikanac koji je nekoliko godina ranije za jezik Smalltalk napisao mali alat za testiranje. Drugi je bio Erich Gamma, Švicarac, jedan od četiri autora slavne knjige Design Patterns. Beck je htio naučiti Javu, a Gamma je htio vidjeti Beckov alat za testiranje. Umjesto da gledaju filmove, otvorili su laptop i programirali u paru, nekoliko sati, iznad Atlantika. Pisali su na način koji je Beck propovijedao: prvo test, pa kod koji ga zadovolji. Kad je avion sletio, imali su prvu verziju alata JUnit. Taj mali alat, napravljen tokom jednog leta, postao je jedan od najkorištenijih programa u historiji Jave, a po njegovom uzoru nastali su alati za skoro svaki jezik: NUnit za C#, PHPUnit, CppUnit, unittest za Python. Cijela porodica zove se xUnit. Pouka priče nije samo da se dobre stvari mogu napraviti brzo. Pouka je da je jedinični test toliko jednostavna ideja da stane u let avionom: mali program koji provjeri mali dio drugog programa, i to radi svaki put kad ga pozovete.

U poglavlju 4 napisali smo prve automatske testove u pytestu i vidjeli piramidu testova: najviše testova treba biti u podnožju, na nivou jedinice. Ovo poglavlje uči kako se ti testovi pišu dobro. Prvo vidimo šta je jedinica i kako izgleda dobar jedinični test (obrazac pripremi–izvrši–provjeri). Zatim alate pytesta koji štede posao: parametrizaciju, provjeru izuzetaka i fixture. Na kraju upoznajemo razvoj vođen testovima (TDD), način rada iz Beckovog aviona, i njime pravimo novu funkciju DeliveryApp: napojnicu za dostavljača.

Literatura. Osnovna literatura predmeta:

  1. J. Popović: Priručnik za test inženjere, CET i Računarski fakultet, Beograd, 2023.
  2. Rakić-Skoković: Priručnik za testiranje softvera, Fakultet tehničkih nauka, Novi Sad, 2013.
  3. G. J. Myers: The Art of Software Testing, 2. izdanje, John Wiley & Sons, 2004.
  4. A. P. Mathur: Foundations of Software Testing, Addison-Wesley Professional, 2008.

Za ovo poglavlje: K. Beck: Test-Driven Development: By Example, Addison-Wesley, 2002; B. Okken: Python Testing with pytest, 2nd ed., Pragmatic Bookshelf, 2022.

Riječi ovog poglavlja.

  • Jedinica – najmanji dio programa koji ima smisla testirati odvojeno: funkcija, metoda ili klasa.
  • Jedinični test – test jedne jedinice, izolovano od ostatka sistema (baze, mreže, ekrana).
  • Pripremi–izvrši–provjeri (Arrange–Act–Assert, AAA) – tri dijela svakog dobrog testa.
  • Parametrizacija – jedan test izvršen više puta s različitim podacima.
  • Izuzetak (exception) – greška koju funkcija namjerno javi kad ulaz nije dozvoljen (u Pythonu raise ValueError).
  • Fixture – pripremljeno stanje ili objekat koji pytest daje testu (npr. prihvaćena narudžba).
  • Razvoj vođen testovima (Test-Driven Development, TDD) – način rada u kojem se prvo napiše test koji pada, pa kod koji ga zadovolji, pa se kod uredi.
  • Crveno–zeleno–refaktoriši – tri koraka TDD-a: test pada, test prolazi, kod se uredi bez mijenjanja ponašanja.
  • Refaktorisanje – mijenjanje unutrašnjosti koda radi jasnoće, bez promjene onoga što kod radi.
  • Test dvojnik (test double) – zamjena za dio sistema koji ne želimo zvati u testu (baza, servis plaćanja); detaljno u poglavlju 9.
  • xUnit – porodica alata za jedinično testiranje nastalih po uzoru na SUnit i JUnit.

Ciljevi poglavlja. Nakon poglavlja možete: reći šta je jedinica i zašto se testira izolovano; napisati jedinični test po obrascu pripremi–izvrši–provjeri; dati testu ime koje kaže šta provjerava; jednim parametrizovanim testom provjeriti više vrijednosti; provjeriti da funkcija javlja izuzetak za nedozvoljen ulaz; napraviti fixture za zajedničku pripremu; objasniti zašto se novac čuva u centima, a ne u decimalnim brojevima; objasniti ciklus crveno–zeleno–refaktoriši; razviti funkciju napojnice postupkom TDD, korak po korak; reći šta jedinični testovi ne mogu otkriti.

Plan poglavlja – Sedmica 5: tri časa teorije i tri časa praktične nastave

Čas Sadržaj Na času
Teorija 1 5.1–5.3 Jedinica; pripremi–izvrši–provjeri; imena testova Test klase Narudzba
Teorija 2 5.4–5.6 Parametrizacija; izuzeci; fixture; zašto centi 0.1 + 0.2 ≠ 0.3
Teorija 3 5.7–5.9 TDD; napojnica korak po korak; granice jediničnih testova Crveno, zeleno, refaktoriši uživo
Praksa 1–3 TDD nove funkcije DeliveryApp; jedinični testovi klase Narudzba napojnica.py i test_napojnica.py

Dio 1 – Dobar jedinični test

5.1 Šta je jedinica

Jedinica je najmanji dio programa koji ima smisla testirati odvojeno. U Pythonu je to najčešće jedna funkcija (cijena_dostave, popust) ili jedna klasa s metodama (Narudzba). Jedinični test provjerava jednu jedinicu, izolovano: bez baze podataka, bez mreže, bez ekrana, bez drugih servisa.

Zašto izolovano? Tri razloga:

  1. Brzina. Funkcija cijena_dostave izvrši se za milionitinke sekunde. Ako bi test išao kroz bazu i mrežu, trajao bi stotine puta duže, i 500 testova bi trajalo minutama umjesto desetinke sekunde.
  2. Tačno mjesto greške. Kad padne jedinični test funkcije popust, greška je u funkciji popust. Kad padne test kroz web stranicu, greška može biti bilo gdje.
  3. Ponovljivost. Funkcija koja samo računa daje isti rezultat svaki put. Baza može biti prazna, mreža spora, servis nedostupan.

Zato je u DeliveryApp pravilo odvojeno od svega ostalog: funkcije cijena_dostave, popust i ukupno ne znaju ništa o bazi ni o ekranu; dobiju brojeve i vrate broj. Ovakav dizajn se zove testabilan: lako ga je testirati. Kad je kod pisan tako da pravilo, baza i ekran žive zajedno u jednoj funkciji od 200 redova, jedinično testiranje postaje teško, i to je znak lošeg dizajna, a ne lošeg testera.

Slika iz života: kontrola kvaliteta u fabrici bicikala. Svaki dio (kočnica, lanac, točak) testira se na posebnom stolu, prije sklapanja. Ako kočnica ne valja, zna se odmah da je kočnica, a ne „nešto na biciklu”.

Ukratko.

  • Jedinica je najmanji dio programa koji se testira odvojeno: funkcija, metoda ili klasa.
  • Jedinični test je izolovan od baze, mreže i ekrana, pa je brz, pokazuje tačno mjesto greške i ponovljiv je.
  • Kod u kojem je pravilo odvojeno od baze i ekrana je testabilan.
  • Kad je jedinično testiranje teško, to je obično znak lošeg dizajna.

5.2 Pripremi–izvrši–provjeri

Skoro svaki dobar test ima tri dijela, uvijek istim redom:

  1. Pripremi (Arrange): napravi ulaz i stanje koje test treba.
  2. Izvrši (Act): pozovi jednu radnju koju testiraš.
  3. Provjeri (Assert): uporedi rezultat s očekivanim.

Primjer: test da se narudžba u pripremi ne može otkazati (PP-6).

import pytest
from deliveryapp import Narudzba

def test_u_pripremi_se_ne_moze_otkazati():
    # Pripremi: narudžba koju je restoran prihvatio i počeo pripremati
    n = Narudzba("N-1001")
    n.promijeni("PRIHVAĆENA")
    n.promijeni("U_PRIPREMI")

    # Izvrši i provjeri: otkazivanje mora javiti grešku
    with pytest.raises(ValueError):
        n.promijeni("OTKAZANA")

    # Provjeri: stanje se nije promijenilo
    assert n.stanje == "U_PRIPREMI"

Prazni redovi između tri dijela nisu ukras. Oni pokazuju čitaocu gdje se šta dešava, i test se čita kao mala priča: „imamo narudžbu u pripremi; pokušamo je otkazati; to ne smije uspjeti, i ona ostaje u pripremi.”

Ovaj test, pokrenut nad prvom verzijom DeliveryApp (DeliveryApp=v0), pada. U prvoj verziji je dozvoljen prelaz iz U_PRIPREMI u OTKAZANA (defekt D-04), pa izuzetak ne bude javljen. pytest kaže:

FAILED tests/test_narudzba.py::test_u_pripremi_se_ne_moze_otkazati - Failed: DID NOT RAISE ValueError

„DID NOT RAISE” znači: očekivali smo grešku, a nije je bilo. Defekt je nađen jednim testom od deset redova, za milisekundu.

Slika 5.1 – Tri dijela jediničnog testa

Ukratko.

  • Dobar test ima tri dijela: pripremi, izvrši, provjeri.
  • Izvršava se jedna radnja, a prazni redovi odvajaju dijelove.
  • Test „narudžba u pripremi se ne može otkazati” pada na prvoj verziji s porukom DID NOT RAISE i tako otkriva D-04.

5.3 Ime i jedna stvar

Kad test padne na serveru za kontinuiranu integraciju u dva ujutro, programer ujutro vidi samo njegovo ime i poruku. Zato ime mora reći šta se provjerava i šta se očekuje:

Loše ime Dobro ime
test1 test_n1001_ukupno_24_50
test_dostava test_dostava_besplatna_od_30_eura
test_narudzba_radi test_u_pripremi_se_ne_moze_otkazati
test_popust_2 test_kod_malim_slovima_daje_popust

Drugo pravilo: jedan test provjerava jedno ponašanje. Test koji provjerava dostavu, popust, otkazivanje i ispis cijene u jednom ima dvije mane. Prvo, kad padne, ne zna se šta je pokvareno. Drugo, prvi assert koji padne zaustavi test, pa ostale provjere uopšte nisu izvršene, i ne znamo da li je i tamo nešto pokvareno.

To ne znači da test smije imati samo jedan assert. Test test_prihvacena_moze_u_pripremu provjerava i stanje i istoriju, jer su to dva dijela istog ponašanja („prelaz je zapisan”). Pravilo je jedno ponašanje, ne jedan red.

Treće pravilo: test ne sadrži logiku. U testu nema if, petlji koje računaju očekivani rezultat, ni kopirane formule iz koda. Očekivani rezultat je konkretan broj, izračunat rukom (test orakul iz poglavlja 1). Test koji računa ocekivano = 200 + 50 * math.ceil(km - 3) samo ponavlja kod, pa ako je formula pogrešna, pogrešni su i kod i test, a test prolazi.

Česta zabluda. „Testove ne treba pisati uredno, to je samo pomoćni kod.” Testovi se čitaju češće od koda: svaki put kad padnu i svaki put kad neko želi saznati šta kod treba raditi. Dobri testovi su živa dokumentacija: iz imena testova DeliveryApp može se pročitati cijeli spisak pravila.

Ukratko.

  • Ime testa kaže šta se provjerava i šta se očekuje, npr. test_dostava_besplatna_od_30_eura.
  • Jedan test provjerava jedno ponašanje; može imati više provjera ako su dio istog ponašanja.
  • Test nema logiku: očekivani rezultat je konkretan broj izračunat rukom.
  • Dobri testovi su živa dokumentacija pravila.

Provjeri se.

  1. Zašto se jedinica testira izolovano od baze? – Da test bude brz, ponovljiv i da pokaže tačno mjesto greške.
  2. Koja su tri dijela dobrog testa? – Pripremi, izvrši, provjeri.
  3. Zašto očekivani rezultat ne smije biti izračunat istom formulom kao u kodu? – Jer bi pogrešna formula dala pogrešan i kod i test, pa bi test prolazio.

Dio 2 – Alati pytesta

5.4 Parametrizacija

Granice dostave traže mnogo sličnih testova: 0 km, 3 km, 3,01 km, 4 km, 4,2 km, 10 km… Osam funkcija koje se razlikuju samo u dva broja je dosadno pisati i lako je pogriješiti pri kopiranju. pytest zato ima parametrizaciju: jedan test, izvršen za svaki red tabele.

import pytest
from deliveryapp import cijena_dostave

@pytest.mark.parametrize("km, ocekivano", [
    (0, 200), (3, 200), (3.01, 250), (3.1, 250),
    (4, 250), (4.2, 300), (9.99, 550), (10, 550),
])
def test_dostava_po_km(km, ocekivano):
    assert cijena_dostave(km, 1000) == ocekivano

Oznaka @pytest.mark.parametrize dobije imena parametara ("km, ocekivano") i listu redova. pytest izvrši test osam puta, svaki put s drugim redom, i svaki red se u izvještaju vidi kao poseban test:

test_granice.py::test_dostava_po_km[0-200] PASSED
test_granice.py::test_dostava_po_km[3-200] PASSED
test_granice.py::test_dostava_po_km[3.01-250] PASSED
...

Kad padne jedan red, ostali se i dalje izvrše, i u izvještaju se tačno vidi koji red je pao. Parametrizovan test je, u stvari, tabela očekivanih rezultata iz poglavlja 1 pretvorena u kod: dodavanje novog slučaja je dodavanje jednog reda.

Ukratko.

  • Parametrizacija izvršava jedan test za svaki red tabele podataka.
  • Svaki red je u izvještaju poseban test, pa se vidi tačno koji je pao.
  • Parametrizovan test je tabela očekivanih rezultata u kodu.

5.5 Izuzeci i decimalni brojevi

Neki ulazi nisu dozvoljeni: 11 km je izvan zone (PP-3), 4,99 € je ispod minimuma (PP-1). Funkcija tada ne vraća broj, nego javlja izuzetak (raise ValueError(...)). I to je ponašanje koje treba testirati. U pytestu se koristi pytest.raises:

@pytest.mark.parametrize("km", [-0.01, 10.01, 11])
def test_izvan_zone(km):
    with pytest.raises(ValueError):
        cijena_dostave(km, 1000)

Blok with pytest.raises(ValueError): kaže: „kod unutar ovog bloka mora javiti ValueError”. Ako ga javi, test prolazi. Ako ga ne javi, test pada s porukom DID NOT RAISE (kao u 5.2). Može se provjeriti i tekst poruke, s match: pytest.raises(ValueError, match="izvan zone").

Testovi izuzetaka se često zaboravljaju, a baš oni hvataju opasne defekte: funkcija koja za 11 km tiho vrati 600 centi umjesto da odbije narudžbu šalje dostavljača izvan grada.

Zašto DeliveryApp računa u centima? U pytestu postoji i pytest.approx za poređenje decimalnih brojeva, i treba ga poznavati. Probajte u Pythonu:

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> round(2.675, 2)
2.67

Računar decimalne brojeve čuva u binarnom obliku, i većina brojeva kao 0,1 ne može se zapisati tačno, isto kao što se 1/3 ne može tačno zapisati decimalno (0,333…). Rezultat je malo pogrešan. Za fiziku je to u redu, i tada se koristi assert x == pytest.approx(0.3), koje dozvoljava sitnu razliku. Ali za novac to nije u redu: kupac ne smije platiti 24,499999 €. Zato DeliveryApp sve iznose čuva kao cijele brojeve centi (24,50 € = 2450), a decimalni zarez se pojavljuje samo pri ispisu na ekran (funkcija eur). Cijeli brojevi se u Pythonu računaju tačno, pa se testovi novca pišu s običnim ==.

Za radoznale. Python ima i modul decimal za tačan decimalni račun, koji koriste banke i knjigovodstveni programi. Cijeli centi su jednostavniji i dovoljni za DeliveryApp.

Ukratko.

  • pytest.raises provjerava da kod javlja izuzetak za nedozvoljen ulaz; ako ga ne javi, test pada s DID NOT RAISE.
  • Testovi izuzetaka hvataju opasne defekte, npr. dostavu izvan zone.
  • 0.1 + 0.2 nije tačno 0.3, jer se decimalni brojevi čuvaju binarno.
  • Zato se novac čuva u cijelim centima, a pytest.approx se koristi samo za mjerenja i fiziku.

5.6 Fixture: zajednička priprema

Testovi klase Narudzba često trebaju isto početno stanje, npr. prihvaćenu narudžbu. Umjesto da svaki test ponavlja dva reda pripreme, pytest ima fixture: funkciju koja pripremi objekat i preda ga testu.

import pytest
from deliveryapp import Narudzba

@pytest.fixture
def prihvacena():
    n = Narudzba("N-1001")
    n.promijeni("PRIHVAĆENA")
    return n

def test_prihvacena_moze_u_pripremu(prihvacena):
    prihvacena.promijeni("U_PRIPREMI")
    assert prihvacena.stanje == "U_PRIPREMI"
    assert prihvacena.istorija == ["NOVA", "PRIHVAĆENA", "U_PRIPREMI"]

def test_prihvacena_se_moze_otkazati(prihvacena):
    assert prihvacena.moze_otkazati()

Kako pytest zna šta da preda? Po imenu: test ima parametar prihvacena, a postoji fixture istog imena, pa ga pytest pozove i rezultat preda testu. Važno: fixture se poziva za svaki test iznova, pa svaki test dobija svoju, novu narudžbu. Kad jedan test promijeni stanje u U_PRIPREMI, drugi test i dalje dobija prihvaćenu narudžbu. To je slovo I iz FIRST (nezavisnost, poglavlje 4).

Fixture se mogu staviti u fajl conftest.py i tada ih vide svi testovi u folderu. U DeliveryApp conftest.py već bira verziju koda (ispravnu ili v0). Fixture mogu i počistiti poslije testa (npr. obrisati privremenu bazu), o čemu više u poglavlju 9.

Ukratko.

  • Fixture je funkcija koja pripremi objekat ili stanje za test, a pytest je poveže s testom po imenu parametra.
  • Svaki test dobija novu kopiju, pa testovi ostaju nezavisni.
  • Fixture u conftest.py vide svi testovi u folderu.

Provjeri se.

  1. Kako se jednim testom provjeri osam udaljenosti? – Parametrizacijom: @pytest.mark.parametrize s listom od osam redova.
  2. Šta znači poruka DID NOT RAISE? – Očekivali smo izuzetak, a kod ga nije javio.
  3. Zašto DeliveryApp čuva novac u centima? – Jer se decimalni brojevi ne čuvaju tačno (0.1 + 0.2 ≠ 0.3), a cijeli brojevi se računaju tačno.

Dio 3 – Razvoj vođen testovima

5.7 Crveno, zeleno, refaktoriši

Do sada smo testove pisali poslije koda. Razvoj vođen testovima (TDD), koji je Kent Beck opisao u knjizi iz 2002. godine, okreće redoslijed. Radi se u kratkom ciklusu od tri koraka, koji traje nekoliko minuta:

  1. Crveno. Napiši jedan mali test za ponašanje koje još ne postoji. Pokreni ga i vidi da pada (pytest ga boji crveno). Ako ne pada, test ne provjerava ništa novo.
  2. Zeleno. Napiši najmanje koda koji zadovolji test. Ne ljepše, ne opštije, samo da test prođe (zeleno).
  3. Refaktoriši. Kad su svi testovi zeleni, uredi kod: bolja imena, bez ponavljanja, konstante umjesto brojeva. Testovi čuvaju da se ponašanje ne promijeni.

Pa ispočetka, sa sljedećim malim testom.

Zašto bi neko radio ovako? Četiri koristi:

  • Svaki red koda ima test, jer je kod nastao da zadovolji test.
  • Test se vidi da pada, pa znamo da zaista nešto provjerava (test koji nikad nije pao možda ne provjerava ništa).
  • Mali koraci: greška je uvijek u posljednjih nekoliko redova, pa se nađe za minutu.
  • Bolji dizajn: ko piše test prvi, prvo misli kako će se funkcija koristiti, pa dobija jednostavnije i testabilne funkcije.

TDD nije uvijek najbolji izbor (za istraživanje nove ideje ili za izgled ekrana manje pomaže), ali za pravila poput onih u DeliveryApp je odličan.

Slika 5.2 – Ciklus crveno–zeleno–refaktoriši

Ukratko.

  • TDD radi u ciklusu: crveno (test pada), zeleno (najmanje koda da prođe), refaktoriši (uredi kod uz zelene testove).
  • Koristi: svaki red koda ima test, test se vidi da pada, mali koraci i bolji dizajn.
  • TDD je odličan za pravila, a manje pomaže kod istraživanja i izgleda ekrana.

5.8 Napojnica, korak po korak

Udruženje restorana traži novo pravilo:

PP-8. Kupac može ostaviti napojnicu dostavljaču od 0, 5, 10 ili 15 % iznosa artikala. Napojnica se zaokružuje naviše na 10 centi i iznosi najviše 10,00 €.

(Recenzija iz poglavlja 2 je pomogla: ovaj put pravilo kaže kako se zaokružuje.) Razvijamo funkciju napojnica(iznos, procenat) postupkom TDD.

Krug 1 – crveno. Najjednostavniji slučaj: bez napojnice.

from napojnica import napojnica

def test_bez_napojnice():
    assert napojnica(2150, 0) == 0

Pokrenemo: pada, jer modul napojnica ne postoji (ModuleNotFoundError, u izvještaju E). Crveno.

Krug 1 – zeleno. Najmanje koda:

def napojnica(iznos, procenat):
    return 0

Zeleno. Da, ovo izgleda smiješno, ali je namjerno: test ne traži više, pa ne pišemo više.

Krug 2 – crveno. Pravi slučaj: 10 % na N-1001. Rukom: 10 % od 21,50 € je 2,15 €, naviše na 10 centi je 2,20 €.

def test_deset_posto_n1001():
    assert napojnica(2150, 10) == 220

Pada: assert 0 == 220. Crveno.

Krug 2 – zeleno, prvi pokušaj. Napišemo return iznos * procenat // 100. Pada: assert 215 == 220. Zaboravili smo zaokruživanje na 10 centi! Test nas je upozorio u sekundi. Drugi pokušaj, sa zaokruživanjem naviše:

def napojnica(iznos, procenat):
    centi = -(-iznos * procenat // 100)   # dijeljenje zaokruženo naviše
    return -(-centi // 10) * 10            # naviše na 10 centi

Zeleno, oba testa. (Trik -(-a // b) daje dijeljenje cijelih brojeva zaokruženo naviše, bez decimalnih brojeva; // sam zaokružuje naniže.)

Krug 3 – crveno. Granica od 10,00 €: 15 % od 90,00 € je 13,50 €, a smije biti najviše 10,00 €.

@pytest.mark.parametrize("iznos, procenat, ocekivano", [
    (2000, 5, 100),      # tačno 1,00 €, nema zaokruživanja
    (2010, 5, 110),      # 1,005 € → 1,01 € → 1,10 €
    (3200, 15, 480),     # N-1002: 4,80 €
    (6660, 15, 1000),    # 9,99 € → 10,00 € (granica)
    (9000, 15, 1000),    # 13,50 € → najviše 10,00 €
])
def test_zaokruzivanje_i_granica(iznos, procenat, ocekivano):
    assert napojnica(iznos, procenat) == ocekivano

Četiri reda prolaze, posljednji pada: assert 1350 == 1000. Dodamo min(..., 1000). Zeleno.

Krug 4 – crveno. Nedozvoljen procenat (3 %, 20 %, −5 %) mora biti odbijen:

@pytest.mark.parametrize("procenat", [-5, 3, 20, 100])
def test_nedozvoljen_procenat(procenat):
    with pytest.raises(ValueError, match="0, 5, 10 ili 15"):
        napojnica(2150, procenat)

Pada: DID NOT RAISE. Dodamo provjeru. Zeleno.

Refaktorisanje. Svi testovi su zeleni, pa uređujemo: brojevi postaju konstante s imenima, dodajemo opis funkcije. Konačna verzija (primjeri/deliveryapp/napojnica.py):

DOZVOLJENI = {0, 5, 10, 15}
MAX_NAPOJNICA = 1000        # 10,00 €

def napojnica(iznos, procenat):
    """Napojnica u centima za iznos artikala u centima."""
    if procenat not in DOZVOLJENI:
        raise ValueError("napojnica može biti 0, 5, 10 ili 15 %")
    centi = -(-iznos * procenat // 100)   # dijeljenje zaokruženo naviše
    na_10 = -(-centi // 10) * 10           # naviše na 10 centi
    return min(na_10, MAX_NAPOJNICA)

Pokrenemo sve testove ponovo: 11 prolaza. Funkcija je nastala za četiri kruga, svaki red ima test, a jednu grešku (zaboravljeno zaokruživanje) test je uhvatio prije nego što je kod iko vidio.

Na času. Predavač radi TDD uživo, a studenti diktiraju sljedeći test. Pravilo igre: niko ne smije napisati red koda dok ne postoji test koji pada.

Ukratko.

  • Napojnica je razvijena u četiri kruga: bez napojnice, 10 % na N-1001, zaokruživanje i granica 10,00 €, nedozvoljen procenat.
  • Test je u drugom krugu uhvatio zaboravljeno zaokruživanje: assert 215 == 220.
  • Refaktorisanje je uvelo konstante, a 11 testova je potvrdilo da se ponašanje nije promijenilo.

5.9 Šta jedinični testovi ne vide

Jedinični testovi su temelj piramide, ali nisu cijela piramida. Oni ne vide:

  • saradnju dijelova: funkcija napojnica je ispravna, ali da li je API zaista poziva i da li ekran prikazuje rezultat? (integracioni testovi, poglavlje 9);
  • pogrešno razumijevanje pravila: ako je programer pogrešno shvatio pravilo, i test i kod su pogrešni na isti način (zato je važna recenzija, poglavlje 2, i prihvatni testovi, poglavlje 10);
  • nefunkcionalne osobine: brzinu pod opterećenjem, sigurnost, upotrebljivost;
  • okruženje: drugu verziju Pythona, drugi operativni sistem, pravu bazu.

Ponekad jedinica zavisi od nečega što u testu ne želimo pozvati: servis za plaćanje, servis mapa, trenutno vrijeme. Tada se taj dio zamijeni test dvojnikom: lažnim objektom koji glumi pravi i vraća ono što test treba. Na primjer, funkcija koja računa udaljenost preko servisa mapa u testu dobija lažni servis koji uvijek vrati 4,2 km. Test dvojnike (lažnjake, stubove i mockove) detaljno učimo u poglavlju 9.

Iz stvarnog svijeta. Mnogi timovi mjere da je većina njihovih defekata „na šavovima”: između dijelova koji su svaki za sebe bili dobro testirani. Heathrow 2008. iz poglavlja 2 je veliki primjer. Zato iznad jediničnih testova uvijek treba bar nekoliko integracionih i sistemskih.

Ukratko.

  • Jedinični testovi ne vide saradnju dijelova, pogrešno shvaćeno pravilo, nefunkcionalne osobine i okruženje.
  • Zavisnosti koje ne želimo zvati u testu zamjenjuju se test dvojnicima (poglavlje 9).
  • Mnogo defekata je na šavovima između dobro testiranih dijelova.

Provjeri se.

  1. Koja su tri koraka TDD-a? – Crveno (test pada), zeleno (najmanje koda da prođe), refaktoriši.
  2. Kolika je napojnica od 10 % na N-1001? – 2,20 € (2,15 € zaokruženo naviše na 10 centi).
  3. Može li jedinični test otkriti da je programer pogrešno shvatio pravilo? – Obično ne, jer su tada i test i kod pogrešni na isti način.

Praktična nastava – TDD u DeliveryApp

Cilj je razviti dvije nove funkcije postupkom TDD i napisati jedinične testove klase Narudzba. Radi se u parovima, po modelu „ping-pong”: jedan piše test koji pada, drugi kod koji ga zadovolji, pa zamjena.

Korak 1 – Ponavljanje napojnice (30 minuta)

  1. Bez gledanja u knjigu, ponovite četiri kruga napojnice iz 5.8 u svom repozitoriju: napojnica.py i tests/test_napojnica.py.
  2. Poslije svakog kruga napravite commit s porukom „crveno: …”, „zeleno: …” ili „refaktoriši: …”. Istorija u Gitu mora pokazati ciklus.
  3. Pokrenite pytest -v tests/test_napojnica.py: mora biti 11 prolaza. Uporedite s primjeri/deliveryapp/napojnica.py.

Korak 2 – Nova funkcija: procjena vremena dostave (60 minuta)

Pravilo za TDD:

PP-9. Procijenjeno vrijeme dostave u minutama je: vrijeme pripreme restorana + 4 minute po započetom kilometru, zaokruženo naviše na 5 minuta. Vrijeme pripreme je 20 minuta za R-1, 15 za R-2 i 10 za R-3. Za nepoznat restoran funkcija javlja ValueError.

  1. Prije koda napišite tabelu od bar šest slučajeva s očekivanim vremenom, izračunatim rukom. Primjer: R-1, 4,2 km → 20 + 4 × 5 = 40 minuta → 40.
  2. Razvijte vrijeme_dostave(restoran, km) u krugovima crveno–zeleno–refaktoriši. Svaki krug jedan commit.
  3. Na kraju: parametrizovan test za sve redove tabele i test izuzetka za nepoznat restoran.
  4. Recenzija u paru: šta pravilo ne kaže? (Npr. šta je sa 0 km, i da li „započeti kilometar” znači da 3,0 km daje 3 ili 4 kilometra.) Zapišite nalaze kao u poglavlju 2.

Korak 3 – Klasa Narudzba (45 minuta)

  1. Napravite tests/test_moja_narudzba.py s fixtureom prihvacena (5.6) i još jednim fixtureom na_putu (narudžba prošla NOVA → PRIHVAĆENA → U_PRIPREMI → NA_PUTU).
  2. Napišite testove po obrascu pripremi–izvrši–provjeri: bar dva dozvoljena prelaza, bar tri nedozvoljena (s pytest.raises) i test istorije.
  3. Pokrenite nad obje verzije (DeliveryApp=v0). Koji test hvata D-04?

Korak 4 – Decimalni brojevi (15 minuta)

U Python konzoli probajte 0.1 + 0.2, round(2.675, 2) i sum([0.1] * 10). Zapišite rezultate. Zatim napišite funkciju ukupno_eur(stavke) koja sabira cijene u eurima kao decimalne brojeve (9.5, 2.5, 0.1 …) i nađite listu stavki za koju zbir nije tačan. Objasnite zašto DeliveryApp koristi cente.

Korak 5 – AI kao par (opcionalno)

Zatražite od AI asistenta da napiše funkciju vrijeme_dostave i testove za nju odjednom. Pokrenite te testove nad vašom implementacijom i vaše testove nad AI implementacijom. Da li se slažu? Ako ne, ko je u pravu po pravilu PP-9? Zapišite primjer u kojem je AI napisao test koji samo ponavlja svoju formulu (logika u testu, 5.3).

Rezultat koji se predaje: repozitorij s napojnica.py, vrijeme_dostave.py i testovima, istorija commitova koja pokazuje cikluse TDD-a, tests/test_moja_narudzba.py s fixtureima, i fajl rezultati-5.md s tabelom PP-9, nalazima recenzije, rezultatima nad v0 i primjerima iz koraka 4.

Vježbe

Vježba za studenta 1 – pripremi, izvrši, provjeri.

Označite tri dijela u testu i recite šta nije u redu:

def test_narudzba():
    n = Narudzba("N-1005")
    n.promijeni("PRIHVAĆENA")
    assert n.stanje == "PRIHVAĆENA"
    n.promijeni("U_PRIPREMI")
    assert n.stanje == "U_PRIPREMI"
    n.promijeni("NA_PUTU")
    n.promijeni("ISPORUČENA")
    assert n.istorija[-1] == "ISPORUČENA"

Rješenje. Pripremi je prvi red; ostatak je naizmjenično izvrši i provjeri. Test provjerava četiri prelaza u jednom, pa kad padne, ne zna se koji prelaz je pokvaren, a ime test_narudzba ne kaže ništa. Bolje: odvojeni testovi s imenima kao test_nova_moze_biti_prihvacena, ili jedan test test_puni_put_do_isporuke koji provjerava cijelu istoriju na kraju.

Vježba za studenta 2 – parametrizacija.

Napišite parametrizovan test za popust: 21,50 € → 2,15 €; 49,90 € → 4,99 €; 50,00 € → 5,00 €; 80,00 € → 5,00 €; nepoznat kod STUDENT20 na 21,50 € → 0,00 €.

Rješenje.

@pytest.mark.parametrize("iznos, kod, ocekivano", [
    (2150, "STUDENT10", 215), (4990, "STUDENT10", 499), (5000, "STUDENT10", 500),
    (8000, "STUDENT10", 500), (2150, "STUDENT20", 0),
])
def test_popust(iznos, kod, ocekivano):
    assert popust(iznos, kod) == ocekivano

Vježba za studenta 3 – izuzetak.

Napišite test koji provjerava da je narudžba od 4,99 € na 2 km odbijena s porukom koja sadrži riječ „minimalne”.

Rješenje.

def test_ispod_minimuma_odbijeno():
    with pytest.raises(ValueError, match="minimalne"):
        cijena_dostave(2, 499)

(Poruka u kodu je „iznos manji od minimalne narudžbe”.)

Vježba za studenta 4 – napojnica rukom.

Izračunajte napojnicu po PP-8: a) 5 % na 24,50 €; b) 15 % na 32,00 €; c) 10 % na 99,99 €; d) 15 % na 70,00 €.

Rješenje. a) 1,225 € → 123 centa → naviše na 10: 1,30 €. b) 4,80 € → 4,80 €. c) 9,999 € → 1000 centi → 10,00 €. d) 10,50 € → najviše 10,00 €.

Vježba za studenta 5 – TDD redoslijed.

Student je napisao cijelu funkciju vrijeme_dostave, pa onda pet testova, i svi su odmah prošli. Je li to TDD? Šta je rizik?

Rješenje. Nije: testovi su napisani poslije koda i nijedan nije viđen da pada. Rizik je da neki test ne provjerava ono što misli (npr. pogrešan parametar, ili očekivani rezultat prepisan iz koda), a mi to ne znamo, jer test koji nikad nije pao možda ne provjerava ništa. Brza provjera: namjerno pokvariti kod i vidjeti da li testovi padnu (ideja testiranja mutacijama, poglavlje 7).

Sažetak u pet rečenica

  1. JUnit, prvi veliki alat porodice xUnit, nastao je 1997. u avionu prema Atlanti, napisan „test prije koda”, i pokazuje koliko je jednostavna ideja jediničnog testa.
  2. Jedinični test provjerava jednu funkciju ili klasu izolovano, pa je brz, ponovljiv i pokazuje tačno mjesto greške, a ima tri dijela: pripremi, izvrši, provjeri.
  3. Dobar test ima ime koje kaže šta provjerava, provjerava jedno ponašanje i nema logike, a očekivani rezultat je konkretan broj izračunat rukom.
  4. Parametrizacija, pytest.raises i fixture štede posao, a novac se čuva u cijelim centima jer 0.1 + 0.2 u računaru nije tačno 0.3.
  5. TDD radi u ciklusu crveno–zeleno–refaktoriši, njime je za četiri kruga nastala napojnica s 11 testova, a jedinični testovi ne vide saradnju dijelova ni pogrešno shvaćeno pravilo.

Pitanja za ponavljanje

  1. Kako je nastao JUnit i šta je porodica xUnit?
  2. Šta je jedinica i zašto se jedinični test izvodi izolovano?
  3. Šta znači da je kod testabilan? Šta govori kod koji je teško jedinično testirati?
  4. Objasnite obrazac pripremi–izvrši–provjeri na primjeru.
  5. Kakvo ime treba imati test? Dajte loš i dobar primjer.
  6. Zašto test ne smije sadržavati logiku za računanje očekivanog rezultata?
  7. Šta je parametrizacija i kako izgleda u pytestu?
  8. Kako se testira da funkcija javlja izuzetak? Šta znači DID NOT RAISE?
  9. Zašto 0.1 + 0.2 nije jednako 0.3 i kako DeliveryApp rješava taj problem?
  10. Šta je fixture i zašto svaki test dobija novu kopiju?
  11. Opišite ciklus crveno–zeleno–refaktoriši.
  12. Koje su koristi TDD-a?
  13. Kako je TDD pomogao kod napojnice? Kolika je napojnica od 10 % na N-1001?
  14. Šta jedinični testovi ne mogu otkriti?

Odgovori na pitanja za ponavljanje.

  1. Prvu verziju JUnita napisali su Kent Beck i Erich Gamma 1997. u avionu od Ciriha do Atlante, programirajući u paru i pišući test prije koda; Beck je ranije napravio sličan alat za Smalltalk. xUnit je porodica alata nastalih po tom uzoru: JUnit, NUnit, PHPUnit, CppUnit, unittest i drugi.
  2. Jedinica je najmanji dio programa koji se testira odvojeno: funkcija, metoda ili klasa. Izolovano se testira da bi test bio brz, ponovljiv i da bi pokazao tačno mjesto greške.
  3. Testabilan kod je kod koji je lako testirati, npr. pravila odvojena od baze i ekrana, koja dobiju brojeve i vrate broj. Kod koji je teško jedinično testirati obično ima loš dizajn, npr. pravilo, bazu i ekran pomiješane u jednoj velikoj funkciji.
  4. Pripremi: napravi se narudžba i prevede u U_PRIPREMI. Izvrši: pokuša se promijeniti u OTKAZANA. Provjeri: mora se javiti ValueError, a stanje ostaje U_PRIPREMI.
  5. Ime treba reći šta se provjerava i šta se očekuje. Loše: test1, test_dostava. Dobro: test_dostava_besplatna_od_30_eura, test_u_pripremi_se_ne_moze_otkazati.
  6. Jer bi test ponavljao formulu iz koda, pa bi pogrešna formula dala pogrešan i kod i test, a test bi prolazio. Očekivani rezultat mora biti konkretan broj izračunat rukom.
  7. Parametrizacija izvršava jedan test za svaki red tabele podataka. U pytestu se piše oznaka @pytest.mark.parametrize(“km, ocekivano”, [(0, 200), (3, 200), …]) iznad funkcije s parametrima km i ocekivano.
  8. Blokom with pytest.raises(ValueError): oko poziva; test prolazi ako kod javi ValueError. DID NOT RAISE znači da je izuzetak očekivan, a kod ga nije javio.
  9. Računar decimalne brojeve čuva binarno, a 0,1 se binarno ne može zapisati tačno, pa je zbir malo pogrešan (0.30000000000000004). DeliveryApp sve iznose čuva kao cijele brojeve centi, koji se računaju tačno, a decimalni zarez dodaje samo pri ispisu.
  10. Fixture je funkcija označena s @pytest.fixture koja pripremi objekat ili stanje, a pytest je preda testu koji ima parametar istog imena. Svaki test dobija novu kopiju da bi testovi bili nezavisni: promjena u jednom testu ne utiče na drugi.
  11. Crveno: napiše se jedan mali test za novo ponašanje i vidi se da pada. Zeleno: napiše se najmanje koda da test prođe. Refaktoriši: kod se uredi dok su svi testovi zeleni. Pa ispočetka.
  12. Svaki red koda ima test; svaki test je viđen da pada, pa zaista nešto provjerava; koraci su mali, pa se greška nađe brzo; dizajn je jednostavniji jer se prvo misli kako će se funkcija koristiti.
  13. U drugom krugu test je odmah pokazao zaboravljeno zaokruživanje (assert 215 == 220), prije nego što je kod iko vidio, a na kraju 11 testova pokriva svako pravilo PP-8. Napojnica od 10 % na N-1001 je 2,20 €.
  14. Saradnju dijelova (da li API i ekran zaista koriste funkciju), pogrešno shvaćeno pravilo (test i kod pogrešni na isti način), nefunkcionalne osobine (brzina, sigurnost, upotrebljivost) i probleme okruženja (druga verzija Pythona, prava baza).