Poglavlje 9: Testiranje zasnovano na modelu i integracija
Priča za početak. Ujutro 20. decembra 2019. raketa Atlas V poletjela je s Floride noseći novu Boeingovu kapsulu Starliner, koja je trebala, bez posade, prvi put doletjeti do Međunarodne svemirske stanice. Lansiranje je bilo savršeno. Ali kad se kapsula odvojila od rakete, nije upalila motore da se podigne na pravu orbitu. Njen tajmer misije, sat po kojem kapsula zna u kojoj je fazi leta, pokazivao je pogrešno vrijeme, pomjereno za oko 11 sati. Tajmer je vrijeme uzeo od rakete u pogrešnom trenutku, još prije završnog odbrojavanja. Kapsula je „mislila” da je u drugom dijelu leta i trošila gorivo na pogrešne manevre. Ostalo joj ga je premalo, pa je spajanje sa stanicom otkazano, a kapsula se vratila na Zemlju poslije dva dana. Za vrijeme leta inženjeri su našli i drugu grešku u softveru, koja je mogla izazvati sudar dijelova kapsule pri povratku; ispravili su je sa Zemlje, na vrijeme. Istraga NASA-e i Boeinga otkrila je zašto testovi na zemlji nisu našli nijednu od dvije greške. Simulacija cijelog leta trajala bi više od 25 sati, pa je Boeing testove podijelio na dijelove: jedan dio je testirao let do jedne tačke, drugi od te tačke dalje. Svaki dio je prošao. Ali cijeli let, od lansiranja do spajanja, nikad nije testiran u jednom komadu, a greška tajmera živjela je baš na prelazu između dijelova. Druga greška je promakla jer je simulator koji se koristio u testovima bio stari kod iz drugih projekata, bez tačnog rasporeda Starlinerovih motora. Komisija je dala 61 preporuku, a prva je bila jasna: testirati od kraja do kraja, u cjelini. Ovo poglavlje govori o tome kako se dijelovi sistema testiraju zajedno.

Do sada smo testirali jedinice: funkciju cijena_dostave, funkciju popust, klasu Narudzba, svaku zasebno. U poglavlju 5 smo rekli da jedinični testovi ne vide saradnju dijelova, a u poglavlju 2 da je Heathrow pao na šavu između sistema. Sada učimo kako se testira taj šav. Prvo testiranje zasnovano na modelu: kako od malog, jasnog modela ponašanja (dijagram stanja iz poglavlja 8) napraviti stotine testova automatski. Zatim integraciju: strategije spajanja dijelova i test dvojnike koji glume dijelove kojih još nema. Na kraju testiramo API DeliveryApp, vrata kroz koja web stranica i mobilna aplikacija razgovaraju sa serverom, i ugovor koji ta vrata moraju poštovati.
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: G. Meszaros: xUnit Test Patterns, Addison-Wesley, 2007 (test dvojnici); M. Utting, B. Legeard: Practical Model-Based Testing, Morgan Kaufmann, 2007; dokumentacija FastAPI, odjeljak „Testing” (fastapi.tiangolo.com).
Riječi ovog poglavlja.
- Model – pojednostavljen opis ponašanja sistema (dijagram stanja, tabela pravila) iz kojeg se mogu izvesti testovi.
- Testiranje zasnovano na modelu (model-based testing, MBT) – testovi se automatski prave iz modela, a rezultat sistema poredi se s onim što model predviđa.
- Integraciono testiranje – testiranje saradnje dva ili više dijelova: da li se pozivaju pravilno, s pravim podacima, u pravim jedinicama.
- Šav (interface, sučelje) – mjesto gdje jedan dio sistema predaje podatke drugom.
- Stub – zamjena za donji dio sistema koja vraća unaprijed zadan odgovor.
- Drajver (driver) – zamjena za gornji dio sistema koja poziva dio koji testiramo; u pytestu je to sam test.
- Test dvojnik (test double) – opšti naziv za svaku zamjenu u testu: dummy, stub, špijun, mock, fake.
- API (Application Programming Interface) – dogovoreni način na koji jedan program traži usluge od drugog, npr. HTTP zahtjev
POST /v1/cijena.- Statusni kod – broj u HTTP odgovoru koji kaže kako je zahtjev prošao: 200 u redu, 201 napravljeno, 404 ne postoji, 409 konflikt, 422 neispravni podaci.
- Ugovor (contract) – dogovor između dva dijela o tome šta se šalje i šta se vraća: imena polja, tipovi, jedinice, statusni kodovi.
- Test od kraja do kraja (end-to-end) – test koji prolazi kroz cijeli sistem, od ulaza korisnika do konačnog rezultata.
Ciljevi poglavlja. Nakon poglavlja možete: objasniti šta je model i kako se iz njega prave testovi; napisati test koji poredi implementaciju s modelom kroz mnogo slučajnih nizova radnji; objasniti zašto su ponovljivi slučajni testovi važni; opisati strategije integracije (veliki prasak, odozgo nadolje, odozdo nagore, sendvič); razlikovati stub, drajver, špijuna i fake; napraviti integracioni test sa stubom i sa „pravim” servisom; prepoznati grešku na šavu između dva dijela; testirati HTTP API kroz FastAPI TestClient; provjeriti statusne kodove i ugovor API-ja.
Plan poglavlja – Sedmica 9: tri časa teorije i tri časa praktične nastave
| Čas | Sadržaj | Na času |
|---|---|---|
| Teorija 1 | 9.1–9.3 Model kao izvor testova; slučajni nizovi radnji | 200 nizova radnji za jednu narudžbu |
| Teorija 2 | 9.4–9.6 Strategije integracije; test dvojnici; greška na šavu | Metri ili kilometri? |
| Teorija 3 | 9.7–9.9 API, HTTP i statusni kodovi; TestClient; ugovor | POST /v1/cijena za N-1001 |
| Praksa 1–3 | Testovi modela, naručivanja i API-ja DeliveryApp | tests/test_model.py, test_narucivanje.py, test_api.py |
Dio 1 – Model kao izvor testova
9.1 Šta je model
Model je pojednostavljen opis onoga što sistem treba da radi. Nije sam sistem, nego „mapa” sistema: dovoljno mala da se razumije na prvi pogled, a dovoljno tačna da se iz nje vidi šta je ispravno. Već smo ih koristili:
- dijagram stanja narudžbe iz poglavlja 8 (sedam stanja, sedam prelaza);
- tabelu odlučivanja za dostavu i popust iz poglavlja 8;
- graf toka kontrole iz poglavlja 6 (model koda, a ne pravila);
- dijagram aktivnosti ili tok ekrana: korpa → adresa → plaćanje → potvrda.
Slika iz života: maketa zgrade kod arhitekte. Maketa nije zgrada, u njoj se ne može stanovati, ali iz nje se odmah vidi da li su vrata na pravom mjestu. Ako se zgrada i maketa razlikuju, jedno od njih ne valja.
Testiranje zasnovano na modelu (MBT) koristi model na dva načina:
- kao generator testova: program prolazi kroz model i pravi nizove radnji (npr. „prihvati, počni pripremu, otkaži”);
- kao proročište (test oracle): za svaki korak model kaže šta treba da se desi, pa se rezultat sistema automatski uporedi s modelom.
Druga uloga je ključna. Najteži dio svakog testa je očekivani rezultat: do sada smo ga računali rukom za svaki test. Kad je model jednostavan i tačan, on sam izračuna očekivani rezultat za hiljade testova.
Ukratko.
- Model je pojednostavljen opis ponašanja: dijagram stanja, tabela pravila, tok ekrana.
- Testiranje zasnovano na modelu automatski pravi testove iz modela.
- Model služi i kao proročište: za svaki korak kaže šta je ispravan rezultat.
- Ako se sistem i model razlikuju, greška je u jednom od njih, i to treba istražiti.
9.2 Model narudžbe u kodu
Dijagram stanja iz poglavlja 8 možemo zapisati kao mali rječnik u Pythonu: za svaki par (stanje, radnja) piše koje je novo stanje. Fajl tests/test_model.py:
# Model iz specifikacije: (stanje, radnja) -> novo stanje
MODEL = {
("NOVA", "prihvati"): "PRIHVAĆENA",
("NOVA", "odbij"): "ODBIJENA",
("NOVA", "otkaži"): "OTKAZANA",
("PRIHVAĆENA", "počni pripremu"): "U_PRIPREMI",
("PRIHVAĆENA", "otkaži"): "OTKAZANA",
("U_PRIPREMI", "preuzmi"): "NA_PUTU",
("NA_PUTU", "isporuči"): "ISPORUČENA",
}Ako para nema u rječniku, radnja nije dozvoljena. Model je toliko jednostavan da ga može provjeriti i vlasnik proizvoda koji ne zna programirati: sedam redova, sedam strelica na dijagramu.
Zatim test koji napravi slučajan niz od osam radnji i u svakom koraku poredi klasu Narudzba s modelom:
@pytest.mark.parametrize("sjeme", range(200))
def test_slucajni_niz_radnji(sjeme):
rng = random.Random(sjeme) # isto sjeme -> isti niz: test je ponovljiv
n, stanje_modela = Narudzba("N-1001"), "NOVA"
for _ in range(8):
radnja = rng.choice(list(RADNJE))
ocekivano = MODEL.get((stanje_modela, radnja))
if ocekivano is None:
with pytest.raises(ValueError):
n.promijeni(RADNJE[radnja])
else:
n.promijeni(RADNJE[radnja])
stanje_modela = ocekivano
assert n.stanje == stanje_modela, f"poslije radnje {radnja!r}"Šta se dešava u svakom koraku:
- izabere se slučajna radnja (npr. „otkaži”);
- model kaže da li je dozvoljena i u koje stanje vodi;
- ako nije dozvoljena,
Narudzbamora javitiValueError; - ako jeste,
Narudzbamora preći u isto stanje kao model.
Parametar sjeme (seed) od 0 do 199 daje 200 različitih nizova, ukupno 1600 koraka. Nad ispravnom verzijom:
pytest -q tests/test_model.py
200 passed in 0.13s
Nad prvom verzijom (DeliveryApp=v0), u kojoj se narudžba u pripremi može otkazati (D-04):
FAILED tests/test_model.py::test_slucajni_niz_radnji[6] - Failed: DID NOT RAISE ...
...
11 failed, 189 passed in 0.18s
Jedanaest od dvjesto nizova slučajno je došlo do stanja U_PRIPREMI i onda probalo „otkaži”. Nismo morali znati gdje je greška: model ju je našao sam.
Ukratko.
- Model narudžbe je rječnik od sedam redova koji za par (stanje, radnja) daje novo stanje.
- Test pravi slučajne nizove radnji i u svakom koraku poredi implementaciju s modelom.
- 200 nizova od po osam radnji prolazi nad ispravnom verzijom za manje od pola sekunde.
- Nad prvom verzijom 11 nizova pada i tako otkriva D-04, bez ručno napisanog testa za taj slučaj.
9.3 Slučajno, ali ponovljivo
Slučajni testovi imaju jednu opasnost: ako test padne danas, a sutra ga pokrenemo s drugim slučajnim brojevima, možda neće pasti. Tada ne možemo ni naći grešku ni provjeriti ispravku. Rješenje je sjeme (seed): generator slučajnih brojeva pokrenut istim sjemenom daje uvijek isti niz. Test test_slucajni_niz_radnji[6] uvijek pravi isti niz radnji, na svakom računaru, i uvijek pada nad v0 na istom mjestu. To je prvo svojstvo dobrog testa iz poglavlja 4: ponovljivost.
Testiranje zasnovano na modelu ima i svoju cijenu:
- model mora biti tačan: ako je pogrešan, test će „hvatati” ispravan kod; zato se model prepisuje iz specifikacije i recenzira;
- model mora biti jednostavniji od sistema: ako je model složen kao kod, samo smo napisali program dva puta;
- ne vidi sve: naš model zna stanja, ali ne zna cijene, vrijeme, korisnike.
Za radoznale. Za Python postoji biblioteka Hypothesis, koja ovu ideju razvija dalje. Umjesto da sami pišemo petlju sa slučajnim radnjama, opišemo pravila („ovo je stanje, ovo su radnje, ovo mora uvijek važiti”), a Hypothesis sam pravi nizove. Kad nađe niz koji obara test, skraćuje ga do najkraćeg mogućeg, npr. do „prihvati, počni pripremu, otkaži”, pa programer odmah vidi suštinu greške. Ovaj način se zove testiranje svojstava (property-based testing).
Ukratko.
- Slučajni testovi moraju biti ponovljivi, a to se postiže fiksnim sjemenom generatora.
- Isto sjeme daje isti niz radnji na svakom računaru, pa se pad može ponoviti i ispravka provjeriti.
- Model mora biti tačan i mnogo jednostavniji od sistema.
- Biblioteka Hypothesis automatski pravi nizove i skraćuje onaj koji obara test.
Provjeri se.
- Koje su dvije uloge modela u testiranju zasnovanom na modelu? – Generator testova i proročište koje kaže ispravan rezultat.
- Zašto se slučajnom generatoru daje sjeme? – Da isti test uvijek pravi isti niz, pa je pad ponovljiv.
- Koju grešku prve verzije model narudžbe hvata? – D-04: otkazivanje iz stanja U_PRIPREMI.
Dio 2 – Integracija
9.4 Strategije integracije
DeliveryApp nije jedna funkcija. Kad kupac naruči, dešava se lanac:
- servis mapa izračuna udaljenost adrese od restorana;
- pravila DeliveryApp izračunaju ukupno (
ukupno, koja zovecijena_dostaveipopust); - servis plaćanja (banka) naplati iznos;
- napravi se nova narudžba u stanju NOVA.
Taj lanac je u fajlu primjeri/deliveryapp/narucivanje.py:
class Narucivanje:
def __init__(self, mape, placanje):
self.mape = mape # ima udaljenost_m(adresa) -> metri
self.placanje = placanje # ima naplati(iznos_centi) -> broj transakcije ili None
self.brojac = 1000
def naruci(self, stavke, adresa, kod=None):
"""Izračuna iznos, naplati ga i vrati novu narudžbu."""
km = self.mape.udaljenost_m(adresa) / 1000
iznos = ukupno(stavke, km, kod)
transakcija = self.placanje.naplati(iznos)
if transakcija is None:
raise RuntimeError("plaćanje odbijeno")
self.brojac += 1
n = Narudzba(f"N-{self.brojac}")
return n, iznos, transakcijaKojim redom spajati i testirati dijelove? Postoje četiri strategije:
| Strategija | Kako | Prednost | Mana |
|---|---|---|---|
| veliki prasak (big bang) | sve se spoji odjednom i testira | nema dodatnog koda | kad padne, ne zna se gdje; greške se nađu kasno |
| odozgo nadolje (top-down) | prvo gornji dio (Narucivanje), a donji se zamijene stubovima |
rano se vidi cijeli tok | treba pisati stubove |
| odozdo nagore (bottom-up) | prvo donji dijelovi (cijena_dostave, popust), a gornji glumi drajver |
donji dijelovi su temeljno testirani | cijeli tok se vidi tek na kraju |
| sendvič | odozgo i odozdo u isto vrijeme, sastaju se u sredini | brzo, više timova radi paralelno | složenije za organizovati |

U DeliveryApp smo do sada radili odozdo nagore: u poglavljima 4–8 testirali smo cijena_dostave, popust i Narudzba, a drajver je bio sam pytest test, koji poziva funkciju kao što bi je zvao gornji dio. Sada idemo korak gore: Narucivanje koristi te funkcije i dva spoljna servisa. Servisi mapa i banke nisu naši, a u testu ih ne želimo zvati (spori su, naplaćuju, zahtijevaju internet). Zato ih zamjenjujemo.
Starliner je, na neki način, primjer velikog praska bez praska: dijelovi su testirani odvojeno, a cjelina nikad. Strategija integracije mora uvijek završiti testom od kraja do kraja.
Ukratko.
- Integraciono testiranje provjerava saradnju dijelova, a strategija određuje redoslijed spajanja.
- Veliki prasak spaja sve odjednom i otežava nalaženje greške.
- Odozgo nadolje koristi stubove umjesto donjih dijelova, a odozdo nagore drajvere umjesto gornjih.
- Svaka strategija mora završiti testom cijelog toka, od kraja do kraja.
9.5 Test dvojnici
Test dvojnik je objekat koji u testu glumi pravi dio sistema. Ime dolazi iz filma: kad je scena opasna, umjesto glumca igra dubler. Postoji pet vrsta, i dobro je znati razliku (slika 9.3):

| Vrsta | Šta radi | U DeliveryApp |
|---|---|---|
| dummy | samo popunjava parametar; nikad se ne koristi | objekat poslan funkciji koja ga ne dira |
| stub | vraća unaprijed zadan odgovor | StubMape(4200): svaka adresa je 4200 m |
| špijun (spy) | radi kao stub, ali zapamti kako je pozvan | SpyPlacanje: pamti iznose u listi pozivi |
| mock | unaprijed zna koje pozive mora dobiti i sam provjeri | poglavlje 11 (unittest.mock) |
| fake | zaista radi, ali pojednostavljeno | PlacanjeServis: lista u memoriji umjesto prave banke |
Dvojnici u tests/test_narucivanje.py su obične male klase, bez ikakve biblioteke:
class StubMape:
"""Stub: uvijek vrati istu udaljenost, u METRIMA, kao što kaže ugovor servisa mapa."""
def __init__(self, metri):
self.metri = metri
def udaljenost_m(self, adresa):
return self.metri
class SpyPlacanje:
"""Špijun: odobri svaku naplatu i zapamti s kojim iznosom je pozvan."""
def __init__(self):
self.pozivi = []
def naplati(self, iznos_centi):
self.pozivi.append(iznos_centi)
return "T-TEST"I testovi, svaki s očekivanim rezultatom iz pravila:
def test_naplacuje_se_ukupan_iznos():
placanje = SpyPlacanje()
n, iznos, _ = Narucivanje(StubMape(4200), placanje).naruci(N1001, "bilo koja adresa")
assert iznos == 2450 # 21,50 € + 3,00 € dostave (4,2 km)
assert placanje.pozivi == [2450] # banka je pozvana jednom, s tačnim iznosom
assert n.stanje == "NOVA"
def test_izvan_zone_banka_se_ne_poziva():
placanje = SpyPlacanje()
with pytest.raises(ValueError):
Narucivanje(StubMape(11000), placanje).naruci(N1001, "bilo koja adresa")
assert placanje.pozivi == [] # novac se ne skida za narudžbu koja ne može biti dostavljenaDrugi test pokazuje zašto je špijun koristan. Nije dovoljno da naruci javi grešku za 11 km. Važno je i šta se nije desilo: banka nije pozvana. Da je kod prvo naplatio pa onda računao dostavu, kupac bi platio narudžbu koja nikad neće stići. Takvu grešku vidi samo test koji gleda saradnju dva dijela.
Česta zabluda. „Što više dvojnika, to bolje: testovi su brži.” Dvojnik je pretpostavka o tome kako se pravi dio ponaša. Ako je pretpostavka pogrešna, test prolazi, a sistem ne radi. Test sa samim dvojnicima nije integracioni test, nego jedinični test u kostimu. Zato uz testove s dvojnicima uvijek treba bar nekoliko testova s pravim (ili vjerno lažnim) dijelovima.
Ukratko.
- Test dvojnik glumi pravi dio sistema u testu, kao dubler u filmu.
- Stub vraća zadan odgovor, špijun pamti pozive, mock sam provjerava očekivane pozive, a fake radi pojednostavljeno.
- Špijun pokazuje i ono što se nije smjelo desiti, npr. naplatu narudžbe izvan zone.
- Dvojnik je pretpostavka o pravom dijelu, pa testovi s dvojnicima ne zamjenjuju integraciju s pravim dijelovima.
9.6 Greška na šavu: metri ili kilometri?
Servis mapa ima ugovor, zapisan u servisi.py: „udaljenost_m(adresa) vraća udaljenost u metrima, cijeli broj”. Pravila DeliveryApp (ukupno, cijena_dostave) rade u kilometrima. Neko mora pretvoriti metre u kilometre, i to je posao klase Narucivanje: km = self.mape.udaljenost_m(adresa) / 1000.
U prvoj verziji (narucivanje_v0.py) programer je to zaboravio:
km = self.mape.udaljenost_m(adresa) # I-01: metri poslani kao kilometriŠta se desi? Adresa „Lipa 12” je 4200 m daleko. Funkcija cijena_dostave dobije 4200 km i odbije narudžbu: „udaljenost izvan zone dostave”. Svaka narudžba, osim onih na manje od 10 metara, bude odbijena.
Ovo je klasična greška na šavu (integraciona greška): svaki dio je ispravan za sebe. MapeServis vraća metre, kako kaže ugovor. cijena_dostave računa kilometre, kako kaže PP-2. Jedinični testovi oba dijela prolaze. Greška je u predaji podataka između njih, kao kod Mars Climate Orbitera iz poglavlja 1 (funte i njutni), i kao kod Starlinera (tajmer uzet u pogrešnom trenutku).
Testovi nad prvom verzijom:
pytest -q tests/test_narucivanje.py # sa DeliveryApp=v0
FAILED tests/test_narucivanje.py::test_naplacuje_se_ukupan_iznos - ValueError...
FAILED tests/test_narucivanje.py::test_odbijeno_placanje_ne_pravi_narudzbu - ...
FAILED tests/test_narucivanje.py::test_n1001_s_pravim_servisima - ValueError:...
FAILED tests/test_narucivanje.py::test_n1002_s_pravim_servisima - ValueError:...
4 failed, 3 passed in 0.16s
Svaki pad je ValueError: udaljenost izvan zone dostave. Primijetite: stub StubMape(4200) je poštovao ugovor (vraća metre), pa je i on uhvatio grešku. Da je programer sam napisao stub koji vraća 4.2, jer je mislio da servis vraća kilometre, njegov test s dvojnikom bi prošao, a greška bi otišla u produkciju. Zato stubove treba praviti iz ugovora, a ne iz onoga što misli kod koji se testira.
Najbolja zaštita su ugovorni testovi (contract tests): mali testovi koji provjeravaju da pravi servis zaista radi ono što ugovor kaže.
def test_ugovor_mape_vraca_metre_kao_cijeli_broj():
d = MapeServis().udaljenost_m("Lipa 12")
assert isinstance(d, int) and d == 4200
def test_ugovor_mape_nepoznata_adresa():
with pytest.raises(KeyError):
MapeServis().udaljenost_m("Nepostojeća 99")Ako tim koji održava servis mapa jednog dana promijeni odgovor u kilometre, ugovorni test padne odmah, a ne tek kad kupci počnu dobijati pogrešne cijene.
Iz stvarnog svijeta. Velike firme s mnogo servisa (stotine malih programa koji se međusobno zovu) koriste alate za ugovorno testiranje, npr. Pact. Tim koji koristi servis zapiše šta očekuje, a tim koji održava servis automatski provjerava da to i dalje isporučuje, pri svakoj izmjeni.
Ukratko.
- Greška na šavu nastaje kad su oba dijela ispravna, ali se podaci pogrešno predaju između njih.
- U prvoj verziji naručivanja metri su poslani kao kilometri (I-01), pa je svaka narudžba bila izvan zone.
- Stub mora poštovati ugovor pravog servisa, inače i on dijeli pogrešnu pretpostavku koda.
- Ugovorni testovi provjeravaju da pravi servis i dalje radi ono što ugovor kaže.
Provjeri se.
- Šta je drajver u integraciji odozdo nagore? – Zamjena za gornji dio koja poziva dio koji testiramo; u pytestu je to sam test.
- Koja je razlika između stuba i špijuna? – Stub samo vraća zadan odgovor; špijun uz to pamti kako je pozvan, pa test može provjeriti pozive.
- Zašto jedinični testovi nisu našli grešku I-01? – Jer su oba dijela ispravna za sebe; greška je u predaji podataka između njih.
Dio 3 – API i ugovor
9.7 HTTP API i statusni kodovi
Web stranica i mobilna aplikacija DeliveryApp ne zovu Python funkcije direktno. One šalju HTTP zahtjeve serveru, a server odgovara. Skup tih dogovorenih zahtjeva je API. DeliveryApp ima mali API u fajlu primjeri/deliveryapp/api.py, napravljen alatom FastAPI:
| Metoda i putanja | Šta radi | Uspješan odgovor |
|---|---|---|
GET /v1/jela |
jelovnik (opciono ?restoran=R-2) |
200 i lista jela |
POST /v1/cijena |
izračuna cijenu bez pravljenja narudžbe | 200 i račun |
POST /v1/narudzbe |
napravi narudžbu | 201 i broj narudžbe |
GET /v1/narudzbe/{broj} |
stanje i istorija narudžbe | 200 |
PATCH /v1/narudzbe/{broj}/stanje |
promijeni stanje | 200; 409 ako prelaz nije dozvoljen |
Zahtjev i odgovor su u formatu JSON. Primjer za N-1001:
POST /v1/cijena
{"kupac": "K-01", "udaljenost_km": 4.2,
"stavke": [{"jelo_id": 1, "kolicina": 2}, {"jelo_id": 3, "kolicina": 1}]}200 OK
{"artikli": 2150, "popust": 0, "dostava": 300, "ukupno": 2450, "valuta": "EUR", "jedinica": "cent"}Primijetite dvije stvari. Klijent šalje samo jela i količine, ne i cijenu: server sam računa (komentar u kodu kaže: „ne vjeruje broju koji bi poslao klijent”, pouka Heartbleeda iz poglavlja 7). I odgovor kaže jedinicu: iznosi su u centima, u eurima. Pouka greške I-01: jedinica je dio ugovora.
Statusni kod je prvi broj u odgovoru i kaže kako je prošlo:
| Kod | Značenje | Kada u DeliveryApp |
|---|---|---|
| 200 OK | uspjeh | cijena izračunata, stanje promijenjeno |
| 201 Created | nešto je napravljeno | nova narudžba |
| 404 Not Found | ne postoji | jelo 99, narudžba N-9999 |
| 409 Conflict | sukob sa stanjem | otkazivanje narudžbe u pripremi (PP-6) |
| 422 Unprocessable | podaci nisu ispravni | 11 km (PP-3), količina 0 |
Tester API-ja provjerava oba: statusni kod i sadržaj. Odgovor 200 s pogrešnim iznosom je greška; i odgovor 500 (Internal Server Error, server je pao) umjesto 422 je greška, čak i kad je narudžba ispravno odbijena.
Ukratko.
- API je skup dogovorenih HTTP zahtjeva kroz koje web stranica i aplikacija razgovaraju sa serverom.
- Zahtjevi i odgovori DeliveryApp su u formatu JSON, a iznosi su u centima, što piše i u odgovoru.
- Server sam računa cijenu i ne vjeruje brojevima koje šalje klijent.
- Statusni kod kaže ishod: 200, 201, 404, 409, 422; test provjerava i kod i sadržaj.
9.8 TestClient: API bez servera
Da bismo testirali API, ne moramo pokretati pravi server ni otvarati mrežu. FastAPI ima TestClient, koji šalje zahtjeve direktno aplikaciji, u istom procesu. Fajl tests/test_api.py (treba pip install fastapi httpx2):
from fastapi.testclient import TestClient
from api import app
klijent = TestClient(app)
N1001 = {"kupac": "K-01", "udaljenost_km": 4.2,
"stavke": [{"jelo_id": 1, "kolicina": 2}, {"jelo_id": 3, "kolicina": 1}]}
def test_cijena_n1001():
r = klijent.post("/v1/cijena", json=N1001)
assert r.status_code == 200
assert r.json() == {"artikli": 2150, "popust": 0, "dostava": 300, "ukupno": 2450,
"valuta": "EUR", "jedinica": "cent"}
def test_izvan_zone_422():
r = klijent.post("/v1/cijena", json={**N1001, "udaljenost_km": 11})
assert r.status_code == 422
assert r.json()["detail"] == "udaljenost izvan zone dostave"
def test_kolicina_nula_422():
r = klijent.post("/v1/cijena", json={**N1001, "stavke": [{"jelo_id": 1, "kolicina": 0}]})
assert r.status_code == 422 # odbija ga model podataka (Field(gt=0)), prije našeg kodaZapis {**N1001, "udaljenost_km": 11} znači „isto kao N-1001, samo s 11 km”: tako svaki test mijenja tačno jedan ulaz (nevažeće klase jedna po jedna, poglavlje 8).
Test životnog ciklusa koristi fixture koji preko API-ja napravi novu narudžbu:
@pytest.fixture
def broj():
"""Nova narudžba N-1001 preko API-ja; vraća njen broj."""
r = klijent.post("/v1/narudzbe", json=N1001)
assert r.status_code == 201
return r.json()["broj"]
def test_otkazivanje_u_pripremi_409(broj):
for s in ("PRIHVAĆENA", "U_PRIPREMI"):
klijent.patch(f"/v1/narudzbe/{broj}/stanje", json={"stanje": s})
r = klijent.patch(f"/v1/narudzbe/{broj}/stanje", json={"stanje": "OTKAZANA"})
assert r.status_code == 409 # PP-6: konflikt sa stanjem narudžbe
assert klijent.get(f"/v1/narudzbe/{broj}").json()["stanje"] == "U_PRIPREMI"Rezultat nad ispravnom verzijom je 12 prolaza. Nad prvom verzijom (DeliveryApp=v0):
FAILED tests/test_api.py::test_cijena_n1001 - AssertionError: assert {'artikl...
FAILED tests/test_api.py::test_cijena_n1001_student10 - assert 2400 == 2235
FAILED tests/test_api.py::test_nova_narudzba_je_nova - assert 2400 == 2450
FAILED tests/test_api.py::test_otkazivanje_u_pripremi_409 - assert 200 == 409
4 failed, 8 passed in 0.40s
Iste greške (D-02, D-03, D-04) vidljive su i kroz API: ono što je pogrešno u pravilu, pogrešno je i na vratima sistema. Ali API test provjerava i ono što jedinični test ne vidi: da li server za grešku vraća pravi statusni kod, da li JSON ima prava imena polja, da li PATCH zaista mijenja stanje koje kasnije vraća GET.
Na času. Pokrenite server (
uvicorn api:app --reloadu folderuprimjeri/deliveryapp) i otvoritehttp://127.0.0.1:8000/docs. FastAPI je sam napravio stranicu na kojoj se svaki zahtjev može isprobati dugmetom „Try it out”. Pošaljite N-1001 sa 11 km. Koji statusni kod i koju poruku dobijate?
Ukratko.
- TestClient šalje HTTP zahtjeve aplikaciji direktno, bez pokretanja servera i mreže.
- Svaki API test provjerava statusni kod i sadržaj odgovora.
- Fixture preko API-ja pravi novu narudžbu, pa svaki test počinje od čistog stanja.
- Nad prvom verzijom API testovi otkrivaju D-02, D-03 i D-04 i provjeravaju statusne kodove.
9.9 Ugovor API-ja
Web stranicu DeliveryApp pravi jedan tim, mobilnu aplikaciju drugi, a server treći. Svi se oslanjaju na ugovor: koje putanje postoje, koja polja zahtjev ima, šta se vraća. Ako tim servera preimenuje polje ukupno u total, server radi savršeno, njegovi testovi prolaze, a mobilna aplikacija prikazuje prazan iznos.
FastAPI sam piše ugovor u standardnom formatu OpenAPI, na adresi /openapi.json. Test može provjeriti da putanje na koje se klijenti oslanjaju i dalje postoje:
def test_ugovor_openapi():
"""Ugovor: putanje koje klijenti (web stranica, mobilna aplikacija) koriste moraju postojati."""
putanje = klijent.get("/openapi.json").json()["paths"]
for p in ["/v1/jela", "/v1/cijena", "/v1/narudzbe", "/v1/narudzbe/{broj}", "/v1/narudzbe/{broj}/stanje"]:
assert p in putanjeTri pravila za ugovore, koja i DeliveryApp poštuje:
- Verzija u putanji (
/v1/). Kad treba velika promjena, napravi se/v2/, a/v1/radi dok svi klijenti ne pređu. - Dodavati smijete, mijenjati i brisati ne. Novo polje (
napojnica) ne smeta starim klijentima; preimenovano polje ih ruši. - Jedinice i formati su dio ugovora: centi, a ne eura; kilometri u zahtjevu, metri u servisu mapa. Greška I-01 je bila greška u ugovoru o jedinicama.
Za radoznale. HTTP API se može testirati i ručno, alatima HTTPie (
http POST :8000/v1/cijena …u terminalu) ili Postman (grafički program). Oni su odlični za istraživanje i za izvještaje o defektu („pošaljite ovaj zahtjev i vidjećete 500”). Za regresiju, koja se ponavlja pri svakoj izmjeni, bolji su automatski testovi kaotest_api.py.
Ukratko.
- Ugovor API-ja kaže koje putanje postoje, šta se šalje i šta se vraća, i na njega se oslanjaju svi klijenti.
- FastAPI ugovor sam zapisuje u formatu OpenAPI, a test može provjeriti da su važne putanje tu.
- Verzija u putanji omogućava velike promjene bez rušenja starih klijenata.
- Polja se smiju dodavati, ali ne preimenovati ni brisati, a jedinice su dio ugovora.
Provjeri se.
- Koji statusni kod vraća DeliveryApp kad se otkazuje narudžba u pripremi? – 409 Conflict.
- Zašto klijent ne šalje cijenu jela serveru? – Server ne vjeruje brojevima od klijenta; cijenu računa sam iz jelovnika.
- Šta je opasnije za postojeće klijente: dodati polje
napojnicaili preimenovatiukupnoutotal? – Preimenovati: stari klijenti tražeukupnoi ne nalaze ga.
Praktična nastava – Model, integracija i API DeliveryApp
Cilj je pokrenuti i proširiti testove modela, integracije i API-ja, naći grešku na šavu i provjeriti ugovor. Radi se u parovima. Prije početka: pip install fastapi httpx2 uvicorn.
Korak 1 – Model (40 minuta)
- Pokrenite
pytest -q tests/test_model.pynad ispravnom i nad prvom verzijom. Zapišite koliko nizova pada. - Pokrenite samo jedan pali niz:
pytest -q "tests/test_model.py::test_slucajni_niz_radnji[6]". Dodajteprintu test (ili pročitajte poruku) i zapišite cijeli niz radnji koji dovodi do pada. Koji je najkraći niz koji pokazuje D-04? - Proširite model stanjem REKLAMACIJA iz vježbe 4 poglavlja 8 (samo u modelu). Šta se desi s testom nad sadašnjom klasom
Narudzba? Da li je to greška u kodu ili u modelu?
Korak 2 – Integracija i dvojnici (50 minuta)
- Pokrenite
pytest -q tests/test_narucivanje.pynad obje verzije. Za svaki pad na v0 zapišite poruku. Nađite red s greškom I-01 unarucivanje_v0.py. - Napišite svoj stub
StubMapeKmkoji vraća4.2(kilometre) i test s njim nad v0. Prolazi li? Objasnite zašto je takav stub opasan. - Dodajte test: kod STUDENT10 i adresa „Hrast 3” s 4 × Ćevapi. Očekivani iznos izračunajte iz pravila (vidi 8.4, kolona P4) i provjerite da je banka pozvana s tim iznosom.
- Dodajte test: iznos veći od 200,00 € (npr. 30 × Capricciosa na „Lipa 12”). Šta vraća
PlacanjeServis? Šta mora uraditinaruci?
Korak 3 – API (60 minuta)
- Pokrenite
pytest -q tests/test_api.pynad obje verzije i popunite tabelu: test, statusni kod, očekivano, dobijeno, greška (D-0x). - Dopišite testove:
GET /v1/jela?restoran=R-3(tri jela, koja?); N-1002 prekoPOST /v1/cijena(32,00 €, dostava 0); kod" STUDENT10 "s razmacima; prelaz NOVA → ISPORUČENA (409). - Pokrenite server (
uvicorn api:app --reload) i isti zahtjev za N-1001 pošaljite ručno kroz stranicu/docs(ili HTTPie). Uporedite odgovor s testom. - Pošaljite zahtjev bez polja
kupac. Koji statusni kod dobijate i ko ga je vratio, naš kod ili FastAPI?
Korak 4 – Ugovor (30 minuta)
- U svojoj kopiji
api.pypreimenujte poljeukupnoutotal. Koji testovi padaju? Koji klijenti bi u stvarnosti „pali”? - Vratite ime, pa dodajte novo polje
"napojnica": 0u odgovor. Pada li ijedan test? Zašto je to dozvoljena promjena ugovora, a preimenovanje nije?
Rezultat koji se predaje: fajl rezultati-9.md s brojem palih nizova modela i najkraćim nizom za D-04; opis greške I-01 s redom koda i objašnjenjem zašto je stub s kilometrima opasan; dopunjeni test_narucivanje.py i test_api.py; tabela API testova nad obje verzije; zaključak o promjenama ugovora iz koraka 4.
Vježbe
Vježba za studenta 1 – model.
Napišite model (rječnik) za jednostavan semafor na pješačkom prelazu: stanja CRVENO, ZELENO, TREPĆE_ZELENO; radnje „dugme” (iz CRVENO u ZELENO), „vrijeme” (iz ZELENO u TREPĆE_ZELENO, iz TREPĆE_ZELENO u CRVENO). Koliko je nedozvoljenih parova (stanje, radnja)?
Rješenje.
MODEL = {("CRVENO", "dugme"): "ZELENO",
("ZELENO", "vrijeme"): "TREPĆE_ZELENO",
("TREPĆE_ZELENO", "vrijeme"): "CRVENO"}Parova (stanje, radnja) je 3 × 2 = 6, dozvoljena su 3, pa su 3 nedozvoljena: dugme u ZELENO, dugme u TREPĆE_ZELENO i vrijeme u CRVENO (može se i dogovoriti da se ti zahtjevi samo ignorišu, ali to mora pisati u specifikaciji).
Vježba za studenta 2 – vrsta dvojnika.
Koja je vrsta dvojnika: a) objekat koji uvijek vrati kurs 1,00 za bilo koju valutu; b) baza podataka u memoriji (SQLite) umjesto prave baze na serveru; c) objekat koji pamti sve poslane SMS poruke da bi ih test pročitao; d) objekat None poslan kao „logger” funkciji koja ga u tom testu ne koristi.
Rješenje. a) stub; b) fake; c) špijun; d) dummy.
Vježba za studenta 3 – strategija.
Tim pravi novi modul „Dostavljači” koji zavisi od servisa za GPS (još nije gotov) i od klase Narudzba (gotova i testirana). Koju strategiju integracije i koje dvojnike predlažete?
Rješenje. Odozgo nadolje za GPS: modul „Dostavljači” testirati sa stubom GPS servisa (zadane koordinate), napravljenim po ugovoru GPS tima. Za Narudzba koristiti pravu klasu, jer je gotova i testirana (odozdo nagore je već urađeno). Kad GPS servis bude gotov: ugovorni testovi za GPS i bar jedan test od kraja do kraja s pravim servisom.
Vježba za studenta 4 – statusni kodovi.
Koji statusni kod treba vratiti API DeliveryApp za: a) GET /v1/narudzbe/N-1001 kad narudžba postoji; b) POST /v1/narudzbe s ispravnom korpom; c) POST /v1/cijena sa iznosom 4,50 €; d) PATCH stanja ISPORUČENA → NOVA; e) GET /v1/narudzbe/N-0000.
Rješenje. a) 200; b) 201; c) 422 (PP-1, poruka „iznos manji od minimalne narudžbe”); d) 409 (nedozvoljen prelaz); e) 404.
Vježba za studenta 5 – greška na šavu.
Servis plaćanja po ugovoru prima iznos u eurima kao decimalni broj (24.5), a Narucivanje šalje cente (2450). Šta se desi s N-1001? Koji test bi to uhvatio?
Rješenje. Banka bi naplatila 2450,00 € umjesto 24,50 €, sto puta više. Greška je na šavu: oba dijela su ispravna za sebe. Hvata je ugovorni test servisa plaćanja (šta prima i u kojoj jedinici) i integracioni test s vjernim fake servisom koji poštuje ugovor, uz provjeru špijunom da je naplaćeno tačno 24,50 €. (Usput: ugovor u eurima kao decimalnim brojevima je loš ugovor, poglavlje 5.)
Sažetak u pet rečenica
- Starliner je 2019. imao pogrešan tajmer misije jer je softver testiran u dijelovima, a cijeli let od lansiranja do spajanja nikad u jednom komadu.
- Testiranje zasnovano na modelu pravi testove iz malog, jasnog modela ponašanja, koji je i proročište: 200 slučajnih, ali ponovljivih nizova radnji poredi klasu
Narudzbas modelom i nad prvom verzijom samo pronalazi D-04. - Integraciono testiranje provjerava saradnju dijelova, strategijama veliki prasak, odozgo nadolje sa stubovima, odozdo nagore s drajverima ili sendvič, a uvijek završava testom od kraja do kraja.
- Test dvojnici (dummy, stub, špijun, mock, fake) glume dijelove koje ne želimo zvati, ali moraju poštovati ugovor, jer su greške na šavu, kao metri poslani kao kilometri u I-01, nevidljive jediničnim testovima.
- API DeliveryApp testira se kroz FastAPI TestClient provjerom statusnih kodova i sadržaja, a ugovor (putanje, polja, jedinice) štite ugovorni testovi i pravilo da se polja dodaju, ali ne preimenuju.