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:
- J. Popović: Priručnik za test inženjere, CET i Računarski fakultet, Beograd, 2023.
- Rakić-Skoković: Priručnik za testiranje softvera, Fakultet tehničkih nauka, Novi Sad, 2013.
- G. J. Myers: The Art of Software Testing, 2. izdanje, John Wiley & Sons, 2004.
- 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:
- Brzina. Funkcija
cijena_dostaveizvrš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. - Tačno mjesto greške. Kad padne jedinični test funkcije
popust, greška je u funkcijipopust. Kad padne test kroz web stranicu, greška može biti bilo gdje. - 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:
- Pripremi (Arrange): napravi ulaz i stanje koje test treba.
- Izvrši (Act): pozovi jednu radnju koju testiraš.
- 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.

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.
- Zašto se jedinica testira izolovano od baze? – Da test bude brz, ponovljiv i da pokaže tačno mjesto greške.
- Koja su tri dijela dobrog testa? – Pripremi, izvrši, provjeri.
- 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) == ocekivanoOznaka @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.67Rač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
decimalza 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.
- Kako se jednim testom provjeri osam udaljenosti? – Parametrizacijom: @pytest.mark.parametrize s listom od osam redova.
- Šta znači poruka DID NOT RAISE? – Očekivali smo izuzetak, a kod ga nije javio.
- 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:
- 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.
- Zeleno. Napiši najmanje koda koji zadovolji test. Ne ljepše, ne opštije, samo da test prođe (zeleno).
- 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.

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) == 0Pokrenemo: pada, jer modul napojnica ne postoji (ModuleNotFoundError, u izvještaju E). Crveno.
Krug 1 – zeleno. Najmanje koda:
def napojnica(iznos, procenat):
return 0Zeleno. 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) == 220Pada: 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 centiZeleno, 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
napojnicaje 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.
- Koja su tri koraka TDD-a? – Crveno (test pada), zeleno (najmanje koda da prođe), refaktoriši.
- Kolika je napojnica od 10 % na N-1001? – 2,20 € (2,15 € zaokruženo naviše na 10 centi).
- 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 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.
- 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.
- Razvijte
vrijeme_dostave(restoran, km)u krugovima crveno–zeleno–refaktoriši. Svaki krug jedan commit. - Na kraju: parametrizovan test za sve redove tabele i test izuzetka za nepoznat restoran.
- 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)
- Napravite
tests/test_moja_narudzba.pys fixtureomprihvacena(5.6) i još jednim fixtureomna_putu(narudžba prošla NOVA → PRIHVAĆENA → U_PRIPREMI → NA_PUTU). - Napišite testove po obrascu pripremi–izvrši–provjeri: bar dva dozvoljena prelaza, bar tri nedozvoljena (s
pytest.raises) i test istorije. - 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) == ocekivanoVjež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
- 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.
- 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.
- 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.
- 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.
- 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.