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.

Slika 9.1 – Starliner 2019: svaki dio je testiran, prelaz između dijelova nije

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:

  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: 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:

  1. kao generator testova: program prolazi kroz model i pravi nizove radnji (npr. „prihvati, počni pripremu, otkaži”);
  2. 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:

  1. izabere se slučajna radnja (npr. „otkaži”);
  2. model kaže da li je dozvoljena i u koje stanje vodi;
  3. ako nije dozvoljena, Narudzba mora javiti ValueError;
  4. ako jeste, Narudzba mora 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.

  1. Koje su dvije uloge modela u testiranju zasnovanom na modelu? – Generator testova i proročište koje kaže ispravan rezultat.
  2. Zašto se slučajnom generatoru daje sjeme? – Da isti test uvijek pravi isti niz, pa je pad ponovljiv.
  3. 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:

  1. servis mapa izračuna udaljenost adrese od restorana;
  2. pravila DeliveryApp izračunaju ukupno (ukupno, koja zove cijena_dostave i popust);
  3. servis plaćanja (banka) naplati iznos;
  4. 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, transakcija

Kojim 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

Slika 9.2 – Odozgo nadolje (stubovi umjesto donjih dijelova) i odozdo nagore (drajver umjesto gornjeg)

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):

Slika 9.3 – Pet vrsta test dvojnika
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 dostavljena

Drugi 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.

  1. Šta je drajver u integraciji odozdo nagore? – Zamjena za gornji dio koja poziva dio koji testiramo; u pytestu je to sam test.
  2. 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.
  3. 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 koda

Zapis {**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 --reload u folderu primjeri/deliveryapp) i otvorite http://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 putanje

Tri pravila za ugovore, koja i DeliveryApp poštuje:

  1. Verzija u putanji (/v1/). Kad treba velika promjena, napravi se /v2/, a /v1/ radi dok svi klijenti ne pređu.
  2. Dodavati smijete, mijenjati i brisati ne. Novo polje (napojnica) ne smeta starim klijentima; preimenovano polje ih ruši.
  3. 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 kao test_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.

  1. Koji statusni kod vraća DeliveryApp kad se otkazuje narudžba u pripremi? – 409 Conflict.
  2. Zašto klijent ne šalje cijenu jela serveru? – Server ne vjeruje brojevima od klijenta; cijenu računa sam iz jelovnika.
  3. Šta je opasnije za postojeće klijente: dodati polje napojnica ili preimenovati ukupno u total? – Preimenovati: stari klijenti traže ukupno i 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)

  1. Pokrenite pytest -q tests/test_model.py nad ispravnom i nad prvom verzijom. Zapišite koliko nizova pada.
  2. Pokrenite samo jedan pali niz: pytest -q "tests/test_model.py::test_slucajni_niz_radnji[6]". Dodajte print u test (ili pročitajte poruku) i zapišite cijeli niz radnji koji dovodi do pada. Koji je najkraći niz koji pokazuje D-04?
  3. 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)

  1. Pokrenite pytest -q tests/test_narucivanje.py nad obje verzije. Za svaki pad na v0 zapišite poruku. Nađite red s greškom I-01 u narucivanje_v0.py.
  2. Napišite svoj stub StubMapeKm koji vraća 4.2 (kilometre) i test s njim nad v0. Prolazi li? Objasnite zašto je takav stub opasan.
  3. 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.
  4. Dodajte test: iznos veći od 200,00 € (npr. 30 × Capricciosa na „Lipa 12”). Šta vraća PlacanjeServis? Šta mora uraditi naruci?

Korak 3 – API (60 minuta)

  1. Pokrenite pytest -q tests/test_api.py nad obje verzije i popunite tabelu: test, statusni kod, očekivano, dobijeno, greška (D-0x).
  2. Dopišite testove: GET /v1/jela?restoran=R-3 (tri jela, koja?); N-1002 preko POST /v1/cijena (32,00 €, dostava 0); kod " STUDENT10 " s razmacima; prelaz NOVA → ISPORUČENA (409).
  3. 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.
  4. Pošaljite zahtjev bez polja kupac. Koji statusni kod dobijate i ko ga je vratio, naš kod ili FastAPI?

Korak 4 – Ugovor (30 minuta)

  1. U svojoj kopiji api.py preimenujte polje ukupno u total. Koji testovi padaju? Koji klijenti bi u stvarnosti „pali”?
  2. Vratite ime, pa dodajte novo polje "napojnica": 0 u 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

  1. 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.
  2. 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 Narudzba s modelom i nad prvom verzijom samo pronalazi D-04.
  3. 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.
  4. 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.
  5. 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.

Pitanja za ponavljanje

  1. Zašto testovi na zemlji nisu našli grešku tajmera Starlinera?
  2. Šta je model u testiranju? Navedite tri vrste modela.
  3. Koje su dvije uloge modela u testiranju zasnovanom na modelu?
  4. Kako radi test test_slucajni_niz_radnji i šta je našao nad prvom verzijom?
  5. Zašto slučajni testovi moraju imati sjeme?
  6. Opišite četiri strategije integracije s prednostima i manama.
  7. Šta je stub, a šta drajver? Dajte primjer iz DeliveryApp.
  8. Nabrojte pet vrsta test dvojnika i objasnite razliku.
  9. Šta provjerava test test_izvan_zone_banka_se_ne_poziva i zašto je to važno?
  10. Šta je greška I-01 i zašto je jedinični testovi ne vide?
  11. Zašto stub mora biti napravljen iz ugovora, a ne iz onoga što misli kod?
  12. Šta su statusni kodovi 200, 201, 404, 409 i 422 i kada ih vraća DeliveryApp?
  13. Kako TestClient testira API bez servera? Napišite test za N-1001.
  14. Šta je ugovor API-ja i koje promjene ugovora su sigurne?

Odgovori na pitanja za ponavljanje.

  1. Jer je simulacija leta podijeljena na dijelove (cijela bi trajala više od 25 sati), pa je svaki dio prošao, a cijeli let od lansiranja do spajanja nikad nije testiran u komadu; greška tajmera bila je na prelazu između dijelova.
  2. Model je pojednostavljen opis ponašanja sistema iz kojeg se mogu izvesti testovi. Vrste: dijagram stanja, tabela odlučivanja, dijagram aktivnosti ili tok ekrana (i graf toka kontrole kao model koda).
  3. Generator testova (pravi nizove radnji) i proročište (za svaki korak kaže ispravan rezultat).
  4. Za svako sjeme od 0 do 199 napravi niz od osam slučajnih radnji; u svakom koraku model kaže da li je radnja dozvoljena i u koje stanje vodi, a Narudzba mora javiti ValueError ili preći u isto stanje. Nad prvom verzijom 11 nizova pada i otkriva D-04.
  5. Da bi isti test uvijek pravio isti niz: pad se može ponoviti, istražiti i provjeriti ispravka.
  6. Veliki prasak: sve odjednom, bez dodatnog koda, ali teško naći grešku. Odozgo nadolje: gornji dio sa stubovima, rano se vidi tok, ali treba pisati stubove. Odozdo nagore: donji dijelovi s drajverima, temeljno, ali cijeli tok tek na kraju. Sendvič: obje strane paralelno, brzo, ali složenije za organizaciju.
  7. Stub je zamjena za donji dio koja vraća zadan odgovor (StubMape(4200)). Drajver je zamjena za gornji dio koja poziva dio koji testiramo; u DeliveryApp je to pytest test koji poziva cijena_dostave.
  8. Dummy samo popunjava parametar; stub vraća zadan odgovor; špijun vraća odgovor i pamti pozive; mock unaprijed zna koje pozive mora dobiti i sam ih provjerava; fake zaista radi, ali pojednostavljeno (npr. lista u memoriji umjesto banke).
  9. Provjerava da za narudžbu izvan zone naruci javlja grešku i da banka uopšte nije pozvana. Važno je jer kupac ne smije platiti narudžbu koja ne može biti dostavljena; to vidi samo test saradnje dva dijela.
  10. I-01: narucivanje_v0.py šalje metre iz servisa mapa kao kilometre, pa je 4200 m postalo 4200 km i svaka narudžba izvan zone. Jedinični testovi je ne vide jer su servis mapa (vraća metre) i cijena_dostave (računa kilometre) svaki za sebe ispravni.
  11. Jer bi stub napravljen iz pogrešne pretpostavke koda (npr. da servis vraća kilometre) dijelio istu grešku, pa bi test prošao, a sistem ne bi radio s pravim servisom.
  12. 200 OK: uspjeh (cijena, promjena stanja); 201 Created: nova narudžba; 404 Not Found: jelo ili narudžba ne postoji; 409 Conflict: nedozvoljen prelaz stanja, npr. otkazivanje u pripremi; 422: neispravni podaci, npr. 11 km ili količina 0.
  13. TestClient šalje zahtjeve direktno FastAPI aplikaciji u istom procesu. Test: r = klijent.post("/v1/cijena", json=N1001); assert r.status_code == 200; assert r.json()["ukupno"] == 2450.
  14. Ugovor je dogovor o putanjama, poljima, tipovima, jedinicama i statusnim kodovima API-ja. Sigurno je dodati novo polje ili novu putanju; preimenovanje i brisanje polja ili promjena jedinica ruše klijente, pa se za velike promjene pravi nova verzija (/v2/).