Poglavlje 11: Objektno orijentisano testiranje

Priča za početak. Sredinom osamdesetih godina u bolnicama u SAD-u i Kanadi radio je Therac-25, aparat za liječenje raka zračenjem, koji je pravila kanadska firma AECL. Aparat je imao dva načina rada: slabi snop elektrona i vrlo jak snop koji se, prije nego što stigne do pacijenta, mora rasuti kroz metalnu ploču. Ako jak snop krene bez ploče, pacijent dobije dozu stotinama puta veću od propisane. Između juna 1985. i januara 1987. desilo se šest poznatih nesreća s takvim prevelikim dozama; neki pacijenti su umrli, drugi su ostali teško povrijeđeni. Aparat je pri tome pokazivao kratku poruku „MALFUNCTION 54”, koju su operateri navikli preskakati, jer se javljala često i bez posljedica. Uzrok je otkriven tek kada je fizičar bolnice u Teksasu sam sjeo za aparat i naučio da kuca tačno onako brzo kao iskusna operaterka. Ako je operaterka ispravila jedno polje u manje od oko osam sekundi, softver nije primijetio izmjenu: stanje u memoriji i stanje mašine se više nisu slagali. Kasnija analiza, koju su objavili Nancy Leveson i Clark Turner 1993., našla je još dublji uzrok. Velik dio softvera preuzet je sa starijih modela Therac-6 i Therac-20. Na starijim modelima iste greške u softveru postojale su, ali su ih hardverske brave (mehanički osigurači) zaustavljale prije nego što bi naudile pacijentu. Therac-25 je te brave izbacio, jer se vjerovalo softveru „koji godinama radi bez problema”. Stari kod je u novoj mašini izgubio zaštitu na koju je, ne znajući, računao. Ovo poglavlje govori o tome šta to znači za testiranje programa sastavljenih od objekata koji imaju stanje, koji se nasljeđuju i ponovo koriste u novim okolnostima.

Slika 11.1 – Therac-25: preuzet softver bez hardverske brave na koju je ranije računao

DeliveryApp je napisan objektno: klasa Narudzba čuva stanje i istoriju, a Narucivanje sarađuje sa servisima mapa i plaćanja. U poglavljima 8 i 9 testirali smo prelaze stanja i napravili ručne test dvojnike. Sada gledamo šta je posebno u testiranju objekata. Prvo klasu i stanje: zašto se klasa testira nizovima metoda i šta je invarijanta. Zatim nasljeđivanje i polimorfizam: zašto naslijeđena metoda koja radi u roditelju može pasti u podklasi, i kako isti testovi provjeravaju sve podklase. Na kraju alat unittest.mock, koji pravi test dvojnike automatski, i tehniku patch, kojom se u testu zamjenjuje i ono što je teško zamijeniti, kao trenutno vrijeme.

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: N. G. Leveson, C. S. Turner: „An Investigation of the Therac-25 Accidents”, IEEE Computer, 1993; R. V. Binder: Testing Object-Oriented Systems, Addison-Wesley, 1999; dokumentacija modula unittest.mock (docs.python.org).

Riječi ovog poglavlja.

  • Objekat i klasa – klasa je šablon (npr. Narudzba), objekat je jedan primjerak s vlastitim podacima (narudžba N-1001).
  • Stanje objekta – vrijednosti koje objekat trenutno čuva (stanje „U_PRIPREMI”, istorija prelaza).
  • Enkapsulacija – objekat skriva svoje podatke i dozvoljava promjenu samo kroz svoje metode.
  • Invarijanta klase – tvrdnja koja mora biti tačna za svaki objekat poslije svake javne metode (npr. posljednji element istorije je trenutno stanje).
  • Nasljeđivanje – nova klasa (podklasa) preuzima atribute i metode postojeće (roditelja) i mijenja ili dodaje neke.
  • Polimorfizam – isti poziv (n.promijeni(...)) radi različito zavisno od vrste objekta.
  • Liskovin princip zamjene – objekat podklase mora moći zamijeniti objekat roditelja svuda, a da program i dalje radi ispravno.
  • Ugovorni testovi roditelja – testovi napisani za roditelja koji se izvršavaju i nad svakom podklasom.
  • Mock – test dvojnik koji bilježi pozive i omogućava da test provjeri kako je pozvan.
  • create_autospec – pravi mock koji ima samo metode i parametre prave klase.
  • patch – privremeno zamijeni ime u nekom modulu dvojnikom, samo za vrijeme testa.
  • Trka podataka (race condition) – greška koja se javlja samo pri određenom redoslijedu ili brzini događaja; detaljno u poglavlju 12.

Ciljevi poglavlja. Nakon poglavlja možete: objasniti po čemu se testiranje objekata razlikuje od testiranja funkcija; testirati klasu kroz nizove metoda i stanje; napisati i provjeravati invarijantu klase; objasniti zašto naslijeđena metoda mora biti ponovo testirana u podklasi; primijeniti Liskovin princip zamjene; napisati testove roditelja koji se automatski izvršavaju nad svim podklasama; napraviti mock sa unittest.mock, zadati mu povratnu vrijednost ili izuzetak i provjeriti pozive; objasniti zašto je create_autospec sigurniji od običnog mocka; zamijeniti trenutno vrijeme u testu tehnikom patch; prepoznati pretjerano korištenje mockova.

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

Čas Sadržaj Na času
Teorija 1 11.1–11.3 Šta je posebno u objektima; stanje i nizovi metoda; invarijanta Istorija mora završavati trenutnim stanjem
Teorija 2 11.4–11.6 Nasljeđivanje; Liskovin princip; ugovorni testovi za podklase; polimorfizam Narudžba za preuzimanje i KeyError
Teorija 3 11.7–11.9 unittest.mock, create_autospec, patch; kad mockova ima previše Banka ne odgovara
Praksa 1–3 Invarijante, nova podklasa, mock i patch u DeliveryApp vrste_narudzbi.py, radno_vrijeme.py, tests/test_oo.py

Dio 1 – Klasa i stanje

11.1 Šta je posebno u objektima

Funkcija cijena_dostave je kao kalkulator: isti ulaz, isti izlaz, uvijek. Objekat je više kao automat za kafu: ono što se desi kad pritisnete dugme zavisi od toga šta se desilo prije (da li ste ubacili novac, da li je ostalo mlijeka). Zato testiranje objekata ima četiri posebna izazova:

Osobina Izazov za testera U DeliveryApp
stanje rezultat metode zavisi od prethodnih poziva; testiraju se nizovi metoda promijeni("OTKAZANA") radi u NOVA, a ne radi u U_PRIPREMI
enkapsulacija podaci su skriveni; test ih vidi samo kroz javne metode i atribute stanje se mijenja samo kroz promijeni, ne direktno
nasljeđivanje podklasa preuzima metode koje su testirane u drugom kontekstu NarudzbaZaPreuzimanje (11.4)
polimorfizam isti poziv radi različito za različite klase; svaka varijanta treba test n.promijeni(...) za dvije vrste narudžbe

Dobra vijest: alati koje već znamo pokrivaju veliki dio. Prelazi stanja (poglavlje 8) i model (poglavlje 9) su upravo testovi nizova metoda. Ovdje dodajemo invarijante, nasljeđivanje i automatske dvojnike.

Ukratko.

  • Objekat ima stanje, pa rezultat metode zavisi od prethodnih poziva, kao kod automata za kafu.
  • Klasa se zato testira nizovima metoda, a ne samo pojedinačnim pozivima.
  • Enkapsulacija znači da test vidi objekat kroz javne metode.
  • Nasljeđivanje i polimorfizam traže da se ponašanje provjeri za svaku vrstu objekta.

11.2 Invarijanta klase

Neke stvari o narudžbi moraju biti tačne uvijek, bez obzira kroz koje je metode prošla:

  1. istorija počinje stanjem NOVA;
  2. posljednji element istorije je trenutno stanje;
  3. u istoriji nema dva ista stanja zaredom (nema „prelaza” iz stanja u isto stanje).

Takva tvrdnja zove se invarijanta klase (od latinskog „nepromjenljiv”). Slika iz života: knjigovodstvo. Šta god se knjižilo tokom dana, na kraju aktiva mora biti jednaka pasivi. Ako nije, negdje je greška, i to se vidi odmah, bez obzira koja knjiženja su urađena.

Invarijanta se u testu piše jednom, kao pomoćna funkcija, i provjerava poslije svake metode:

def provjeri_invarijantu(n):
    assert n.istorija[0] == "NOVA"
    assert n.istorija[-1] == n.stanje
    assert all(a != b for a, b in zip(n.istorija, n.istorija[1:]))   # nema „prelaza” u isto stanje


def test_invarijanta_kroz_cijeli_put():
    n = Narudzba("N-1001")
    provjeri_invarijantu(n)
    for s in ["PRIHVAĆENA", "U_PRIPREMI", "NA_PUTU", "ISPORUČENA"]:
        n.promijeni(s)
        provjeri_invarijantu(n)

Posebno je važno provjeriti invarijantu poslije neuspjele metode. Metoda koja javi grešku ne smije ostaviti objekat „napola promijenjen”:

def test_invarijanta_poslije_odbijenog_prelaza():
    n = Narudzba("N-1001")
    with pytest.raises(ValueError):
        n.promijeni("ISPORUČENA")
    provjeri_invarijantu(n)                  # neuspjela metoda ne smije pokvariti objekat
    assert n.istorija == ["NOVA"]

Zamislite da je programer u promijeni napisao prvo self.istorija.append(novo), pa tek onda provjeru. Prelaz bi bio odbijen, ali bi „ISPORUČENA” ostala zapisana u istoriji. Stanje bi bilo NOVA, a istorija bi govorila drugo: stanje u objektu i zapis o njemu se više ne bi slagali, upravo kao memorija i mašina kod Therac-25. Invarijanta bi to uhvatila odmah.

Invarijanta se može dodati i u model iz poglavlja 9: poslije svakog od 1600 koraka slučajnih nizova pozovite provjeri_invarijantu(n). Tako jedan red koda provjerava stanje objekta u hiljadama situacija.

Ukratko.

  • Invarijanta klase je tvrdnja koja mora važiti za svaki objekat poslije svake javne metode.
  • Za narudžbu: istorija počinje sa NOVA, završava trenutnim stanjem i nema ponovljenih stanja zaredom.
  • Invarijanta se piše jednom i provjerava poslije svake metode, posebno poslije neuspjele.
  • Neuspjela metoda ne smije ostaviti objekat napola promijenjen.

11.3 Testiranje kroz javni interfejs

Objekat skriva svoje unutrašnje podatke (enkapsulacija). Da li test smije „zaviriti” unutra? Pravilo je: testirajte kroz javni interfejs, kao što objekat koriste drugi dijelovi programa. Za Narudzba to su metode promijeni i moze_otkazati i atributi stanje i istorija.

Zašto ne direktno? Uporedite dva testa:

# Loše: test postavlja stanje „na silu”, mimo pravila
n = Narudzba("N-1001")
n.stanje = "U_PRIPREMI"             # istorija je i dalje ["NOVA"]: objekat je u nemogućem stanju

# Dobro: test dovodi objekat u stanje istim putem kao pravi program
n = Narudzba("N-1001")
n.promijeni("PRIHVAĆENA")
n.promijeni("U_PRIPREMI")

Prvi test pravi objekat koji u stvarnom programu ne može postojati (stanje U_PRIPREMI s istorijom samo NOVA). Šta god da provjeri, ne govori ništa o stvarnom ponašanju. Zato fixture prihvacena iz poglavlja 5 i funkcija narudzba_u_stanju iz poglavlja 8 dovode narudžbu u stanje kroz dozvoljene prelaze.

Drugi razlog: test koji zavisi od unutrašnjosti puca pri svakom refaktorisanju. Ako programer sutra istoriju čuva kao listu parova (stanje, vrijeme), testovi kroz javni interfejs i dalje rade, a testovi koji čitaju unutrašnje podatke moraju se mijenjati, iako se ponašanje nije promijenilo.

Ukratko.

  • Objekat se testira kroz javni interfejs, onako kako ga koriste drugi dijelovi programa.
  • Postavljanje stanja „na silu” pravi objekte koji u stvarnom programu ne mogu postojati.
  • Objekat se u traženo stanje dovodi dozvoljenim putem, npr. fixtureom.
  • Testovi kroz javni interfejs ne pucaju kad se promijeni unutrašnjost, a ponašanje ostane isto.

Provjeri se.

  1. Šta je invarijanta klase? – Tvrdnja koja mora važiti za svaki objekat poslije svake javne metode.
  2. Zašto se invarijanta provjerava i poslije neuspjele metode? – Da se provjeri da metoda koja javi grešku nije ostavila objekat napola promijenjen.
  3. Zašto je loše u testu napisati n.stanje = "U_PRIPREMI"? – Pravi objekat koji u programu ne može postojati (istorija se ne slaže sa stanjem), pa test ne govori ništa o stvarnom ponašanju.

Dio 2 – Nasljeđivanje i polimorfizam

11.4 Naslijeđena metoda u novom kontekstu

DeliveryApp uvodi novu uslugu: kupac može hranu preuzeti sam u restoranu. Takva narudžba nema dostave, pa umjesto NA_PUTU i ISPORUČENA ima stanja SPREMNA i PREUZETA. Programer pravi podklasu koja nasljeđuje Narudzba i mijenja samo metodu promijeni, s novom tabelom prelaza:

PRELAZI_PREUZIMANJE = {
    "NOVA": {"PRIHVAĆENA", "ODBIJENA", "OTKAZANA"},
    "PRIHVAĆENA": {"U_PRIPREMI", "OTKAZANA"},
    "U_PRIPREMI": {"SPREMNA"},
    "SPREMNA": {"PREUZETA"},
    "PREUZETA": set(),
    "ODBIJENA": set(),
    "OTKAZANA": set(),
}


class NarudzbaZaPreuzimanje(Narudzba):
    """Narudžba koju kupac sam preuzima u restoranu."""

    def promijeni(self, novo):
        if novo not in PRELAZI_PREUZIMANJE[self.stanje]:
            raise ValueError(f"nedozvoljen prelaz {self.stanje} -> {novo}")
        self.stanje = novo
        self.istorija.append(novo)

Metoda moze_otkazati je naslijeđena od roditelja, nepromijenjena. Ona je testirana, radi godinama, šta bi moglo poći naopako? Pogledajmo je:

    def moze_otkazati(self):
        return "OTKAZANA" in PRELAZI[self.stanje]       # PRELAZI roditelja!

Ona gleda tabelu roditelja, u kojoj nema stanja SPREMNA. Dok je narudžba za preuzimanje u stanjima koja postoje u obje tabele (NOVA, PRIHVAĆENA, U_PRIPREMI), sve radi. Kad dođe u stanje SPREMNA:

>>> n.moze_otkazati()
    return "OTKAZANA" in PRELAZI[self.stanje]
                         ~~~~~~~^^^^^^^^^^^^^
KeyError: 'SPREMNA'

Slika 11.2 – Naslijeđena metoda gleda podatke roditelja, u kojima novo stanje ne postoji

Ovo je Therac-25 u malom: kod koji je ispravan u starom kontekstu pada u novom, jer je računao na nešto što je u novom kontekstu promijenjeno. Pouka za testera je jasna i ponekad iznenađuje: naslijeđena metoda mora se ponovo testirati u podklasi, iako u njoj nije promijenjen nijedan red. Promijenio se kontekst: podaci, stanja i metode s kojima ona sarađuje.

Ispravka u primjeri/deliveryapp/vrste_narudzbi.py: podklasa zamjenjuje i moze_otkazati, tako da gleda njenu tabelu. (Još bolji dizajn bio bi da roditelj tabelu čuva kao atribut klase, self.PRELAZI, pa da podklasa zamijeni samo tabelu; to je zadatak u praktičnoj nastavi.)

Za radoznale. Programeri ovaj problem zovu „krhka bazna klasa” (fragile base class): podklasa zavisi od detalja roditelja, pa i mala promjena na jednoj strani ruši drugu. Zato mnogi savjetuju „radije sastavljaj nego nasljeđuj” (composition over inheritance): umjesto podklase, objekat koji koristi drugi objekat, npr. narudžba koja dobije tabelu prelaza kao parametar.

Ukratko.

  • Naslijeđena metoda je testirana u kontekstu roditelja, a u podklasi se kontekst mijenja.
  • moze_otkazati naslijeđena u narudžbi za preuzimanje pada s KeyError u stanju SPREMNA.
  • Naslijeđene metode moraju se ponovo testirati u podklasi, iako nijedan red nije promijenjen.
  • Therac-25 je ista greška u velikom: preuzet kod izgubio je zaštitu iz starog konteksta.

11.5 Liskovin princip i ugovorni testovi roditelja

Kad je podklasa „dobra”? Odgovor je dala Barbara Liskov, profesorica s MIT-a, i zove se Liskovin princip zamjene: objekat podklase mora moći zamijeniti objekat roditelja na svakom mjestu u programu, a da program i dalje radi ispravno. Dio programa koji radi s narudžbom (npr. dugme „Otkaži” na ekranu) ne smije morati znati da li je narudžba za dostavu ili za preuzimanje.

Za narudžbu to znači da obje vrste moraju poštovati ugovor roditelja:

  • nova narudžba je u stanju NOVA i može se otkazati;
  • iz PRIHVAĆENA se može otkazati;
  • iz U_PRIPREMI se ne može otkazati (PP-6);
  • moze_otkazati() se u svakom stanju slaže s tim da li promijeni("OTKAZANA") uspijeva, i nikad ne javlja grešku.

Kako testirati ugovor za sve podklase, a ne pisati iste testove dva puta? pytest ima odgovor: parametrizovan fixture. Fixture nova vraća jednom Narudzba, a drugi put NarudzbaZaPreuzimanje, i svaki test koji ga koristi izvrši se za obje:

@pytest.fixture(params=[Narudzba, NarudzbaZaPreuzimanje], ids=["dostava", "preuzimanje"])
def nova(request):
    """Svaki test ispod izvrši se jednom za svaku vrstu narudžbe."""
    return request.param("N-1001")


def test_ugovor_u_pripremi_nema_otkazivanja(nova):
    nova.promijeni("PRIHVAĆENA")
    nova.promijeni("U_PRIPREMI")
    assert not nova.moze_otkazati()
    with pytest.raises(ValueError):
        nova.promijeni("OTKAZANA")           # PP-6 važi za obje vrste


def test_ugovor_moze_otkazati_u_svakom_stanju(nova):
    """moze_otkazati() mora se slagati s promijeni() u svakom stanju na putu narudžbe."""
    put = ["PRIHVAĆENA", "U_PRIPREMI", "NA_PUTU", "ISPORUČENA"] if type(nova) is Narudzba \
        else ["PRIHVAĆENA", "U_PRIPREMI", "SPREMNA", "PREUZETA"]
    for s in put:
        nova.promijeni(s)
        nova.moze_otkazati()                 # ne smije javiti grešku ni u jednom stanju
pytest -v tests/test_oo.py -k ugovor
tests/test_oo.py::test_ugovor_pocinje_kao_nova[dostava] PASSED
tests/test_oo.py::test_ugovor_pocinje_kao_nova[preuzimanje] PASSED
...
tests/test_oo.py::test_ugovor_moze_otkazati_u_svakom_stanju[dostava] PASSED
tests/test_oo.py::test_ugovor_moze_otkazati_u_svakom_stanju[preuzimanje] PASSED

Posljednji test je onaj koji hvata grešku iz 11.4: s naslijeđenom moze_otkazati varijanta [preuzimanje] pada s KeyError: 'SPREMNA'. Kad sutra neko doda treću vrstu (npr. NarudzbaZaFirmu), dovoljno je dodati je u listu params, i svi ugovorni testovi se sami izvrše i nad njom.

Nad prvom verzijom (DeliveryApp=v0) pada test_ugovor_u_pripremi_nema_otkazivanja[dostava]: naš stari poznanik D-04. Zanimljivo, varijanta [preuzimanje] prolazi, jer podklasa ima svoju tabelu prelaza u kojoj greške nema.

Ukratko.

  • Liskovin princip: objekat podklase mora moći zamijeniti objekat roditelja bilo gdje, a program i dalje radi ispravno.
  • Ugovor roditelja su pravila koja moraju važiti za svaku podklasu, npr. PP-6.
  • Parametrizovan fixture izvršava iste ugovorne testove nad svakom podklasom.
  • Nova podklasa se samo doda u listu params i odmah prolazi kroz sve ugovorne testove.

11.6 Polimorfizam

Polimorfizam znači da isti poziv radi različito zavisno od vrste objekta. Dugme „Sljedeći korak” u aplikaciji restorana poziva n.promijeni(...), a da li je sljedeće stanje NA_PUTU ili SPREMNA, zna sam objekat. To je velika prednost za programera, ali obaveza za testera: svaka varijanta mora biti testirana na mjestu gdje se koristi.

Praktična pravila:

  1. Za svaku podklasu, svaka zamijenjena metoda ima svoje testove (npr. test_preuzimanje_nema_dostave: narudžba za preuzimanje ne smije ići u NA_PUTU).
  2. Za svaku podklasu, ugovorni testovi roditelja (11.5).
  3. Za svako mjesto koje koristi polimorfni poziv, bar po jedan test sa svakom vrstom objekta (dugme „Otkaži” s narudžbom za dostavu i s narudžbom za preuzimanje).

Broj kombinacija raste brzo: tri vrste narudžbe × četiri mjesta poziva = 12 kombinacija. Ovdje pomaže testiranje po parovima iz poglavlja 8, i to što ugovorni testovi već pokrivaju zajedničko ponašanje.

Česta zabluda. „Podklasa ima samo jednu novu metodu, pa testiram samo tu metodu.” Polimorfizam radi i u suprotnom smjeru: naslijeđene metode roditelja mogu pozivati zamijenjenu metodu podklase, pa nova metoda mijenja ponašanje starih. Zato se pri dodavanju podklase pokreću svi testovi roditelja nad njom.

Ukratko.

  • Polimorfizam znači da isti poziv radi različito za različite vrste objekata.
  • Svaka zamijenjena metoda podklase dobija svoje testove, a svaka podklasa i ugovorne testove roditelja.
  • Mjesto koje koristi polimorfni poziv treba testirati sa svakom vrstom objekta.
  • Nova metoda u podklasi može promijeniti ponašanje i naslijeđenih metoda.

Provjeri se.

  1. Zašto se naslijeđena metoda ponovo testira u podklasi? – Jer se promijenio kontekst (podaci, stanja, druge metode), pa ispravna metoda može pasti, kao moze_otkazati sa SPREMNA.
  2. Šta kaže Liskovin princip zamjene? – Objekat podklase mora moći zamijeniti objekat roditelja bilo gdje, a program i dalje radi ispravno.
  3. Kako pytest izvršava iste testove za više podklasa? – Parametrizovanim fixtureom s listom klasa u params.

Dio 3 – Mock i patch

11.7 unittest.mock

U poglavlju 9 napisali smo test dvojnike ručno: StubMape, SpyPlacanje. Za veće projekte to je mnogo pisanja. Python ima ugrađeni modul unittest.mock koji pravi dvojnike automatski. Najvažnije mogućnosti:

Šta Kako Primjer
napraviti dvojnika Mock() ili create_autospec(Klasa, instance=True) placanje = create_autospec(PlacanjeServis, instance=True)
zadati povratnu vrijednost .return_value placanje.naplati.return_value = "T-0042"
zadati izuzetak .side_effect placanje.naplati.side_effect = ConnectionError(...)
provjeriti poziv .assert_called_once_with(...) placanje.naplati.assert_called_once_with(2450)
provjeriti da nije pozvan .assert_not_called() banka se ne zove za narudžbu izvan zone
pročitati sve pozive .call_args_list, .call_count koliko puta je banka pozvana

Test iz tests/test_oo.py:

def test_mock_banka_pozvana_jednom_s_iznosom():
    placanje = create_autospec(PlacanjeServis, instance=True)
    placanje.naplati.return_value = "T-0042"
    mape = Mock()
    mape.udaljenost_m.return_value = 4200
    _, iznos, transakcija = Narucivanje(mape, placanje).naruci(N1001, "Lipa 12")
    assert (iznos, transakcija) == (2450, "T-0042")
    placanje.naplati.assert_called_once_with(2450)
    mape.udaljenost_m.assert_called_once_with("Lipa 12")

Ovo je mock u pravom smislu riječi (poglavlje 9, slika 9.3): test unaprijed zna kako dvojnik mora biti pozvan („tačno jednom, sa 2450”) i to provjeri. Ako bi naruci greškom pozvao banku dva puta (kupac plati dvaput!), assert_called_once_with bi pao s porukom koliko je poziva bilo.

Još korisnije je glumiti kvar, koji je s pravim servisom teško izazvati:

def test_mock_banka_nedostupna():
    placanje = create_autospec(PlacanjeServis, instance=True)
    placanje.naplati.side_effect = ConnectionError("banka ne odgovara")
    mape = Mock(**{"udaljenost_m.return_value": 4200})
    narucivanje = Narucivanje(mape, placanje)
    with pytest.raises(ConnectionError):
        narucivanje.naruci(N1001, "Lipa 12")
    assert narucivanje.brojac == 1000        # narudžba nije napravljena

Banka „ne odgovara”, a test provjerava da narudžba nije napravljena: kupac ne smije dobiti potvrdu za neplaćenu narudžbu.

Ukratko.

  • Modul unittest.mock automatski pravi test dvojnike.
  • return_value zadaje odgovor, a side_effect izuzetak ili niz odgovora.
  • assert_called_once_with provjerava da je dvojnik pozvan tačno jednom s datim argumentima.
  • Mock omogućava da se glumi kvar, npr. banka koja ne odgovara, koji je s pravim servisom teško izazvati.

11.8 autospec i patch

Obični Mock() ima opasnu osobinu: prihvata sve. Ako u kodu stoji pogrešno ime metode, npr. placanje.naplatiti(iznos) umjesto naplati, obični mock će vratiti novi mock i ništa neće prijaviti. Test prolazi, a s pravom bankom program pada. Zato se za klase koje imamo koristi create_autospec: dvojnik dobija samo metode i parametre koje prava klasa ima.

def test_autospec_hvata_pogresno_ime_metode():
    placanje = create_autospec(PlacanjeServis, instance=True)
    with pytest.raises(AttributeError):
        placanje.naplatiti(2450)             # takve metode nema u pravoj klasi

To je ista pouka kao u poglavlju 9: dvojnik mora poštovati ugovor pravog dijela. create_autospec taj ugovor uzima automatski iz prave klase.

Drugi alat je patch: privremeno zamijeni neko ime u modulu, samo dok traje test. Klasičan primjer je vrijeme. Fajl primjeri/deliveryapp/radno_vrijeme.py:

RADNO_VRIJEME = {"R-1": (10, 23), "R-2": (9, 22), "R-3": (8, 20)}   # od, do (puni sati)


def sada():
    """Trenutno vrijeme. Izdvojeno u funkciju da bi ga test mogao zamijeniti."""
    return dt.datetime.now()


def otvoren(restoran):
    """Da li restoran sada prima narudžbe."""
    od, do = RADNO_VRIJEME[restoran]
    return od <= sada().hour < do

Kako testirati da je Picerija zatvorena u 9 h, ako test pokrećemo u podne? Ne čekamo devet sati sutra ujutro, nego zamijenimo funkciju sada:

@pytest.mark.parametrize("sat, ocekivano", [(9, False), (10, True), (22, True), (23, False)])
def test_picerija_radno_vrijeme(sat, ocekivano):
    with patch("radno_vrijeme.sada", return_value=dt.datetime(2026, 10, 22, sat, 0)):
        assert otvoren("R-1") is ocekivano   # R-1 radi od 10 do 23 h

Primijetite dvije stvari. Prvo, ovo su granične vrijednosti iz poglavlja 8: 9 i 10 h na otvaranju, 22 i 23 h na zatvaranju. Drugo, programer je trenutno vrijeme izdvojio u funkciju sada upravo zato da bi se moglo zamijeniti. To je testabilan dizajn iz poglavlja 5: kod koji direktno zove sat, slučajni broj ili mrežu teško je testirati, a mali „šav” (seam) to rješava.

Na času. Gdje u kodu još postoji „skrivena zavisnost” koju bi trebalo izdvojiti da bi se mogla zamijeniti u testu? – Npr. generator broja narudžbe (N-1001, N-1002…), slučajni izbor dostavljača, slanje SMS-a kupcu, čitanje kursa eura iz banke.

Ukratko.

  • Obični Mock() prihvata svaku metodu, pa ne hvata pogrešno ime metode u kodu.
  • create_autospec pravi dvojnika samo s metodama prave klase i tako poštuje ugovor.
  • patch privremeno zamijeni ime u modulu, npr. funkciju koja vraća trenutno vrijeme.
  • Trenutno vrijeme i slične zavisnosti treba izdvojiti u posebne funkcije da bi bile testabilne.

11.9 Kad mockova ima previše

Mockovi su moćni, i zato opasni. Tri znaka da ih je previše:

  1. Test provjerava kako, a ne šta. Test koji provjerava da je cijena_dostave pozvana tačno jednom, sa 4.2 i 2150, puca čim programer promijeni unutrašnjost (npr. izračuna dostavu ranije), iako je rezultat isti. Bolje: provjeriti rezultat (2450) i samo one pozive koji su dio ugovora sa spoljnim svijetom (naplata u banci, slanje SMS-a).
  2. Mockuje se ono što je naše i brzo. Pravila DeliveryApp (ukupno, popust) su brza i bez spoljnih zavisnosti; njih ne treba mockovati, nego koristiti prave. Mock je za ono što je sporo, skupo, nepouzdano ili tuđe: banka, mapa, mreža, sat.
  3. Test više liči na kod nego na zahtjev. Ako test od 30 redova podešava 8 mockova da bi provjerio jednu liniju, vjerovatno je dizajn previše zapetljan; to je signal za refaktorisanje, ne za još mockova.

Najveća opasnost je stara poznanica iz poglavlja 9: mock je pretpostavka. Svi testovi s mockovima mogu biti zeleni, a sistem s pravom bankom ne raditi. Zato piramida iz poglavlja 4 ima i integracione i sistemske testove iznad jediničnih.

Iz stvarnog svijeta. Therac-25 pokazuje i ovu pouku. Softver je „testiran” na starijim mašinama, u okruženju koje se razlikovalo od novog upravo u onome što je bilo važno: hardverska brava je bila dvojnik koji je gutao greške. Kad se okruženje promijenilo, testovi koji su ranije „prolazili” nisu više značili ništa.

Ukratko.

  • Previše mockova čini testove krhkim: provjeravaju kako kod radi umjesto šta postiže.
  • Mockuje se ono što je sporo, skupo, nepouzdano ili tuđe, a ne naša brza pravila.
  • Test s mnogo mockova za malo koda je znak da dizajn treba pojednostaviti.
  • Testovi s mockovima moraju biti dopunjeni integracionim i sistemskim testovima.

Provjeri se.

  1. Šta radi side_effect? – Zada izuzetak (ili niz odgovora) koji mock javlja kad se pozove, npr. ConnectionError za banku koja ne odgovara.
  2. Zašto je create_autospec sigurniji od Mock()? – Jer ima samo metode i parametre prave klase, pa pogrešno ime metode odmah javi AttributeError.
  3. Šta treba mockovati, a šta ne? – Mockovati sporo, skupo, nepouzdano ili tuđe (banka, mapa, sat); naša brza pravila koristiti prava.

Praktična nastava – Objekti, podklase i mockovi

Cilj je provjeriti invarijante narudžbe, napraviti novu podklasu i provjeriti je ugovornim testovima, i zamijeniti spoljne zavisnosti mockovima i patch. Radi se u parovima.

Korak 1 – Invarijanta (30 minuta)

  1. Pokrenite pytest -v tests/test_oo.py nad ispravnom i nad prvom verzijom; zapišite rezultate.
  2. U svojoj kopiji deliveryapp.py zamijenite redoslijed u promijeni: prvo self.istorija.append(novo), pa provjera. Koji test pada? Da li bi pao i neki test iz poglavlja 8?
  3. Dodajte poziv provjeri_invarijantu(n) poslije svakog koraka u testu modela iz poglavlja 9 (tests/test_model.py) i pokrenite ga nad obje verzije.

Korak 2 – Naslijeđena greška (30 minuta)

  1. U kopiji vrste_narudzbi.py obrišite metodu moze_otkazati iz podklase. Pokrenite pytest -v tests/test_oo.py -k ugovor. Koji test pada i s kojom porukom?
  2. Popravite dizajn: u svojoj kopiji deliveryapp.py neka klasa Narudzba ima atribut klase PRELAZI, a metode koriste self.PRELAZI. Tada podklasa zamijeni samo tabelu, bez ijedne metode. Pokrenite sve testove.

Korak 3 – Nova podklasa (45 minuta)

  1. Napravite podklasu NarudzbaZaFirmu: firma plaća mjesečno, pa nakon ISPORUČENA ide stanje FAKTURISANA. Napišite tabelu prelaza iz specifikacije, prije koda.
  2. Dodajte je u params fixturea nova i pokrenite ugovorne testove. Šta treba dopuniti u testu test_ugovor_moze_otkazati_u_svakom_stanju da bi radio za tri vrste? (Savjet: neka svaka klasa ima listu „puta” ili neka test čita put iz tabele.)
  3. Napišite bar dva testa za ponašanje koje je samo u novoj podklasi.

Korak 4 – Mock i patch (45 minuta)

  1. Napišite test u kojem servis mapa javlja KeyError (nepoznata adresa). Šta treba da se desi s narudžbom? Da li je banka pozvana? Provjerite sa assert_not_called().
  2. Napišite test u kojem banka prvi put javi ConnectionError, a drugi put vrati transakciju (side_effect kao lista). naruci danas ne pokušava ponovo; zapišite kao pitanje za vlasnika proizvoda da li bi trebao.
  3. Testirajte radno vrijeme Roštilja (R-2) i Salatarnice (R-3) na svim granicama, sa patch.
  4. Napišite namjerno „loš” test s mockom koji provjerava da je cijena_dostave pozvana s 4.2 i 2150 (koristite patch("narucivanje.ukupno", wraps=...) ili sličan pristup). Zatim zamislite refaktorisanje koje ne mijenja rezultat, a obara taj test. Objasnite u dvije rečenice zašto je test loš.

Rezultat koji se predaje: fajl rezultati-11.md s rezultatima nad obje verzije, opisom testa koji je uhvatio pogrešan redoslijed u promijeni i greške naslijeđene metode; izmijenjen deliveryapp.py sa self.PRELAZI; NarudzbaZaFirmu s tabelom prelaza i testovima; novi testovi s mockovima i patch; kratko objašnjenje lošeg testa iz koraka 4.4.

Vježbe

Vježba za studenta 1 – invarijanta.

Klasa Korpa ima listu stavke (parovi cijena, količina) i atribut ukupno, koji se ažurira u metodama dodaj i ukloni. Napišite invarijantu.

Rješenje. ukupno je uvijek jednak zbiru cijena × količina svih stavki, i nijedna količina nije manja od 1 (stavka s količinom 0 se uklanja). U kodu:

def provjeri_invarijantu(k):
    assert k.ukupno == sum(c * q for c, q in k.stavke)
    assert all(q >= 1 for _, q in k.stavke)

Provjerava se poslije svakog dodaj i ukloni, i poslije neuspjelog ukloni (uklanjanje stavke koje nema).

Vježba za studenta 2 – Liskov.

Programer pravi podklasu HitnaNarudzba u kojoj se narudžba može otkazati i u stanju U_PRIPREMI, „jer je hitna”. Da li to krši Liskovin princip? Šta bi pokazali ugovorni testovi?

Rješenje. Krši: dio programa koji radi s bilo kojom narudžbom oslanja se na PP-6 (u pripremi nema otkazivanja; restoran je već potrošio namirnice). HitnaNarudzba se ne ponaša kao roditelj, pa je ne može zamijeniti. Ugovorni test test_ugovor_u_pripremi_nema_otkazivanja bi za nju pao. Ako posao zaista traži drugačije pravilo, to nije podklasa iste narudžbe, nego posebna vrsta s posebnim pravilom koje se mora dogovoriti i zapisati.

Vježba za studenta 3 – mock.

Napišite test koji provjerava da se kupcu šalje SMS „Narudžba N-1001 je na putu” kad narudžba pređe u NA_PUTU. Servis za SMS ima metodu posalji(broj_telefona, tekst).

Rješenje. Pretpostavimo da klasa koja vodi narudžbu dobija SMS servis kao parametar:

def test_sms_kad_je_na_putu():
    sms = Mock()
    vodic = VodicNarudzbe(sms, telefon="+387 61 000 000")
    vodic.promijeni("N-1001", "NA_PUTU")
    sms.posalji.assert_called_once_with("+387 61 000 000", "Narudžba N-1001 je na putu")

Telefon u testu je izmišljen. Bolje je koristiti create_autospec(SmsServis, instance=True) ako klasa postoji, da se uhvati pogrešno ime metode.

Vježba za studenta 4 – patch.

Funkcija rok_dostave() vraća vrijeme za 45 minuta od sada (sada() + 45 minuta). Kako testirati da narudžba u 22:30 ima rok u 23:15?

Rješenje. Zamijeniti sada fiksnim vremenom: with patch("modul.sada", return_value=dt.datetime(2026, 10, 22, 22, 30)): pa assert rok_dostave() == dt.datetime(2026, 10, 22, 23, 15). Dodati i granicu iza ponoći: 23:30 → 00:15 sljedećeg dana.

Vježba za studenta 5 – previše mockova.

Test za funkciju ukupno mockuje cijena_dostave (vraća 300) i popust (vraća 0) i provjeri da je rezultat 2450 za N-1001. Šta nije u redu?

Rješenje. cijena_dostave i popust su naša brza pravila bez spoljnih zavisnosti; mockovanjem test više ne provjerava da su pravila tačna, nego samo da ukupno sabira tri broja. Greška D-02 (round umjesto ceil) bi prošla, jer mock uvijek vraća 300. Treba koristiti prave funkcije, a mockovati samo spore, skupe ili tuđe dijelove.

Sažetak u pet rečenica

  1. Therac-25 je 1985–1987. davao smrtonosne doze zračenja jer je softver preuzet sa starijih modela izgubio hardversku bravu na koju je računao, a stanje u memoriji se pri brzom unosu nije slagalo sa stanjem mašine.
  2. Objekti imaju stanje, pa se testiraju nizovima metoda kroz javni interfejs, a invarijanta klase, npr. da istorija završava trenutnim stanjem, provjerava se poslije svake metode, i uspjele i neuspjele.
  3. Naslijeđena metoda mora se ponovo testirati u podklasi, jer se mijenja kontekst, kao što moze_otkazati u narudžbi za preuzimanje pada s KeyError u stanju SPREMNA.
  4. Liskovin princip traži da podklasa može zamijeniti roditelja, a parametrizovan fixture izvršava iste ugovorne testove roditelja nad svakom podklasom.
  5. Modul unittest.mock pravi dvojnike koji glume odgovore i kvarove i provjeravaju pozive, create_autospec čuva ugovor prave klase, patch zamjenjuje i trenutno vrijeme, a mockuje se samo ono što je sporo, skupo, nepouzdano ili tuđe.

Pitanja za ponavljanje

  1. Šta se desilo s aparatom Therac-25 i koje su dvije glavne pouke za testere?
  2. Po čemu se testiranje objekata razlikuje od testiranja funkcija?
  3. Šta je invarijanta klase? Navedite invarijantu klase Narudzba.
  4. Zašto se invarijanta provjerava i poslije neuspjele metode?
  5. Zašto se objekat testira kroz javni interfejs, a ne postavljanjem unutrašnjih podataka?
  6. Zašto naslijeđena metoda mora biti ponovo testirana u podklasi? Navedite primjer iz DeliveryApp.
  7. Šta kaže Liskovin princip zamjene?
  8. Kako se u pytestu isti testovi izvršavaju nad svim podklasama?
  9. Šta je polimorfizam i šta traži od testera?
  10. Šta rade return_value, side_effect i assert_called_once_with?
  11. Zašto je create_autospec bolji od običnog Mock()?
  12. Čemu služi patch? Kako je testirano radno vrijeme Picerije?
  13. Zašto je trenutno vrijeme u radno_vrijeme.py izdvojeno u funkciju sada?
  14. Koji su znaci da test ima previše mockova?

Odgovori na pitanja za ponavljanje.

  1. Therac-25 je između juna 1985. i januara 1987. u šest poznatih nesreća dao ogromne prevelike doze zračenja. Pouke: preuzet kod u novom okruženju može izgubiti zaštitu na koju je računao (hardverske brave), pa se mora ponovo testirati; i stanje u softveru mora se slagati sa stvarnim stanjem, posebno pri brzim i istovremenim događajima.
  2. Objekat ima stanje, pa rezultat metode zavisi od prethodnih poziva i testiraju se nizovi metoda; podaci su skriveni (enkapsulacija); nasljeđivanje i polimorfizam traže provjeru svake vrste objekta.
  3. Invarijanta je tvrdnja koja mora važiti za svaki objekat poslije svake javne metode. Za Narudzba: istorija počinje sa NOVA, posljednji element istorije je trenutno stanje i nema dva ista stanja zaredom.
  4. Da se provjeri da metoda koja javi grešku nije ostavila objekat napola promijenjen, npr. zapisala novo stanje u istoriju iako je prelaz odbijen.
  5. Jer postavljanje podataka „na silu” pravi objekte koji u stvarnom programu ne mogu postojati, a testovi koji čitaju unutrašnjost pucaju pri refaktorisanju iako se ponašanje nije promijenilo.
  6. Jer se u podklasi mijenja kontekst: podaci, stanja i metode s kojima naslijeđena metoda sarađuje. Primjer: moze_otkazati naslijeđena u NarudzbaZaPreuzimanje gleda tabelu roditelja, pa u stanju SPREMNA javi KeyError.
  7. Objekat podklase mora moći zamijeniti objekat roditelja na svakom mjestu u programu, a da program i dalje radi ispravno.
  8. Parametrizovanim fixtureom: @pytest.fixture(params=[Narudzba, NarudzbaZaPreuzimanje]) vraća objekat svake klase, pa se svaki test koji ga koristi izvrši za svaku klasu.
  9. Polimorfizam znači da isti poziv radi različito za različite vrste objekata. Tester mora testirati svaku zamijenjenu metodu, izvršiti ugovorne testove nad svakom podklasom i testirati mjesta poziva sa svakom vrstom objekta.
  10. return_value zadaje šta mock vraća; side_effect zadaje izuzetak ili niz odgovora; assert_called_once_with provjerava da je mock pozvan tačno jednom s datim argumentima.
  11. Jer ima samo metode i parametre prave klase, pa pogrešno ime metode ili pogrešan broj argumenata odmah javlja grešku, dok obični Mock() prihvata sve i sakrije grešku.
  12. patch privremeno zamijeni ime u modulu dvojnikom dok traje test. Funkcija radno_vrijeme.sada zamijenjena je fiksnim vremenom 9, 10, 22 i 23 h, a test provjerava da je Picerija otvorena u 10 i 22 h, a zatvorena u 9 i 23 h.
  13. Da bi se u testu mogla zamijeniti tehnikom patch; kod koji direktno zove sat teško je testirati, a izdvojena funkcija je „šav” koji dizajn čini testabilnim.
  14. Test provjerava kako kod radi umjesto šta postiže; mockuju se naše brze funkcije umjesto spoljnih zavisnosti; test podešava mnogo mockova za malo koda i više liči na kod nego na zahtjev.