Otkrijte testiranje temeljeno na svojstvima s Pythonovom bibliotekom Hypothesis. Nadmašite testove temeljene na primjerima kako biste pronašli rubne slučajeve i izgradili robusniji i pouzdaniji softver.
Iza unit testova: Duboki zaron u testiranje temeljeno na svojstvima s Pythonovim Hypothesisom
U svijetu razvoja softvera, testiranje je temelj kvalitete. Desetljećima je dominantna paradigma bila testiranje temeljeno na primjerima. Pedantno izrađujemo ulazne podatke, definiramo očekivane izlazne podatke i pišemo tvrdnje kako bismo provjerili ponaša li se naš kod prema planu. Ovaj pristup, koji se nalazi u okvirima kao što su unittest
i pytest
, moćan je i neophodan. Ali što ako vam kažem da postoji komplementarni pristup koji može otkriti pogreške za koje nikada niste ni pomislili da ih tražite?
Dobrodošli u svijet testiranja temeljenog na svojstvima, paradigme koja prebacuje fokus s testiranja specifičnih primjera na provjeru općih svojstava vašeg koda. A u Python ekosustavu, neprikosnoveni prvak ovog pristupa je biblioteka koja se zove Hypothesis.
Ovaj sveobuhvatni vodič će vas odvesti od potpunog početnika do samouvjerenog praktičara testiranja temeljenog na svojstvima s Hypothesisom. Istražit ćemo temeljne koncepte, zaroniti u praktične primjere i naučiti kako integrirati ovaj moćan alat u vaš svakodnevni razvojni tijek rada kako biste izgradili robusniji, pouzdaniji softver otporniji na pogreške.
Što je testiranje temeljeno na svojstvima? Promjena načina razmišljanja
Da bismo razumjeli Hypothesis, prvo moramo shvatiti temeljnu ideju testiranja temeljenog na svojstvima. Usporedimo ga s tradicionalnim testiranjem temeljenim na primjerima koje svi poznajemo.
Testiranje temeljeno na primjerima: Poznata staza
Zamislite da ste napisali prilagođenu funkciju sortiranja, my_sort()
. S testiranjem temeljenim na primjerima, vaš proces razmišljanja bi bio:
- "Testirajmo je s jednostavnim, uređenim popisom." ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "Što je s obrnutim redoslijedom popisa?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "Što je s praznim popisom?" ->
assert my_sort([]) == []
- "Popis s duplikatima?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "I popis s negativnim brojevima?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
Ovo je učinkovito, ali ima temeljno ograničenje: testirate samo slučajeve kojih se možete sjetiti. Vaši testovi su dobri samo koliko i vaša mašta. Možda ćete propustiti rubne slučajeve koji uključuju vrlo velike brojeve, netočnosti s pomičnim zarezom, specifične Unicode znakove ili složene kombinacije podataka koje dovode do neočekivanog ponašanja.
Testiranje temeljeno na svojstvima: Razmišljanje u invarijantama
Testiranje temeljeno na svojstvima preokreće scenarij. Umjesto davanja konkretnih primjera, definirate svojstva, ili invarijante, vaše funkcije—pravila koja bi trebala vrijediti za bilo koji valjani unos. Za našu funkciju my_sort()
, ova svojstva mogu biti:
- Izlaz je sortiran: Za bilo koji popis brojeva, svaki element u izlaznom popisu je manji ili jednak onom koji slijedi iza njega.
- Izlaz sadrži iste elemente kao i ulaz: Sortirani popis je samo permutacija izvornog popisa; elementi se ne dodaju ili gube.
- Funkcija je idempotentna: Sortiranje već sortiranog popisa ne bi ga trebalo promijeniti. To jest,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
S ovim pristupom ne pišete testne podatke. Pišete pravila. Zatim dopuštate okviru, poput Hypothesisa, da generira stotine ili tisuće nasumičnih, raznolikih i često lukavih unosa kako bi pokušao dokazati da su vaša svojstva pogrešna. Ako pronađe unos koji krši svojstvo, pronašao je pogrešku.
Uvod u Hypothesis: Vaš automatizirani generator testnih podataka
Hypothesis je vodeća biblioteka za testiranje temeljeno na svojstvima za Python. Uzima svojstva koja definirate i obavlja težak posao generiranja testnih podataka kako bi ih osporio. To nije samo generator slučajnih podataka; to je inteligentan i moćan alat dizajniran za učinkovito pronalaženje pogrešaka.
Ključne značajke Hypothesisa
- Automatsko generiranje testnih slučajeva: Vi definirate *oblik* podataka koji vam je potreban (npr. "popis cijelih brojeva", "niz koji sadrži samo slova", "datum i vrijeme u budućnosti"), a Hypothesis generira širok raspon primjera koji odgovaraju tom obliku.
- Inteligentno smanjivanje: Ovo je čarobna značajka. Kada Hypothesis pronađe neuspjeli testni slučaj (npr. popis od 50 kompleksnih brojeva koji ruši vašu funkciju sortiranja), on ne samo da prijavljuje taj ogroman popis. Inteligentno i automatski pojednostavljuje ulaz kako bi pronašao najmanji mogući primjer koji još uvijek uzrokuje neuspjeh. Umjesto popisa od 50 elemenata, može prijaviti da se neuspjeh događa samo s
[inf, nan]
. To čini otklanjanje pogrešaka nevjerojatno brzim i učinkovitim. - Besprijekorna integracija: Hypothesis se savršeno integrira s popularnim okvirima za testiranje kao što su
pytest
iunittest
. Možete dodati testove temeljene na svojstvima uz postojeće testove temeljene na primjerima bez promjene tijeka rada. - Bogata biblioteka strategija: Dolazi s ogromnom zbirkom ugrađenih "strategija" za generiranje svega, od jednostavnih cijelih brojeva i nizova do složenih, ugniježđenih struktura podataka, datuma i vremena svjesnih vremenskih zona, pa čak i NumPy nizova.
- Testiranje stanja: Za složenije sustave, Hypothesis može testirati nizove radnji kako bi pronašao pogreške u prijelazima stanja, što je notorno teško s testiranjem temeljenim na primjerima.
Početak rada: Vaš prvi Hypothesis test
Zaprljajmo ruke. Najbolji način da razumijete Hypothesis je da ga vidite u akciji.
Instalacija
Prvo ćete morati instalirati Hypothesis i svoj test runner po izboru (koristit ćemo pytest
). To je jednostavno kao:
pip install pytest hypothesis
Jednostavan primjer: Funkcija apsolutne vrijednosti
Razmotrimo jednostavnu funkciju koja bi trebala izračunati apsolutnu vrijednost broja. Malo pogrešna implementacija može izgledati ovako:
# u datoteci pod nazivom `my_math.py` def custom_abs(x): """Prilagođena implementacija funkcije apsolutne vrijednosti.""" if x < 0: return -x return x
Sada napišimo testnu datoteku, test_my_math.py
. Prvo, tradicionalni pytest
pristup:
# test_my_math.py (temeljeno na primjerima) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
Ovi testovi prolaze. Naša funkcija izgleda ispravno na temelju ovih primjera. Ali sada, napišimo test temeljen na svojstvima s Hypothesisom. Koje je temeljno svojstvo funkcije apsolutne vrijednosti? Rezultat nikada ne bi trebao biti negativan.
# test_my_math.py (temeljeno na svojstvima s Hypothesisom) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """Svojstvo: Apsolutna vrijednost bilo kojeg cijelog broja je uvijek >= 0.""" assert custom_abs(x) >= 0
Rastavimo ovo:
from hypothesis import given, strategies as st
: Uvozimo potrebne komponente.given
je dekorator koji pretvara običnu testnu funkciju u test temeljen na svojstvima.strategies
je modul u kojem pronalazimo naše generatore podataka.@given(st.integers())
: Ovo je srž testa. Dekorator@given
govori Hypothesisu da pokrene ovu testnu funkciju više puta. Za svako pokretanje generirat će vrijednost pomoću priložene strategije,st.integers()
, i proslijediti je kao argumentx
našoj testnoj funkciji.assert custom_abs(x) >= 0
: Ovo je naše svojstvo. Tvrdimo da za koji god cijeli brojx
Hypothesis smisli, rezultat naše funkcije mora biti veći ili jednak nuli.
Kada ovo pokrenete s pytest
, vjerojatno će proći za mnoge vrijednosti. Hypothesis će pokušati 0, -1, 1, velike pozitivne brojeve, velike negativne brojeve i još mnogo toga. Naša jednostavna funkcija ispravno obrađuje sve ove. Sada isprobajmo drugu strategiju da vidimo možemo li pronaći slabost.
# Testirajmo s brojevima s pomičnim zarezom @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Ako ovo pokrenete, Hypothesis će brzo pronaći neuspjeli slučaj!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis je otkrio da naša funkcija, kada joj se da float('nan')
(Nije broj), vraća nan
. Tvrdnja nan >= 0
je netočna. Upravo smo pronašli suptilnu pogrešku za koju vjerojatno ne bismo pomislili da je ručno testiramo. Mogli bismo popraviti našu funkciju da obradi ovaj slučaj, možda podizanjem ValueError
ili vraćanjem određene vrijednosti.
Još bolje, što ako je pogreška bila s vrlo specifičnim floatom? Hypothesisov shrinker bi uzeo veliki, složeni broj koji ne uspijeva i smanjio ga na najjednostavniju moguću verziju koja još uvijek pokreće pogrešku.
Moć strategija: Izrada vaših testnih podataka
Strategije su srce Hypothesisa. Oni su recepti za generiranje podataka. Biblioteka uključuje ogroman niz ugrađenih strategija, a možete ih kombinirati i prilagoditi za generiranje gotovo bilo koje strukture podataka koju možete zamisliti.
Uobičajene ugrađene strategije
- Numeričke:
st.integers(min_value=0, max_value=1000)
: Generira cijele brojeve, po izboru unutar određenog raspona.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Generira brojeve s pomičnim zarezom, s finom kontrolom nad posebnim vrijednostima.st.fractions()
,st.decimals()
- Tekst:
st.text(min_size=1, max_size=50)
: Generira Unicode nizove određene duljine.st.text(alphabet='abcdef0123456789')
: Generira nizove iz određenog skupa znakova (npr. za heksadecimalne kodove).st.characters()
: Generira pojedinačne znakove.
- Zbirke:
st.lists(st.integers(), min_size=1)
: Generira popise gdje je svaki element cijeli broj. Imajte na umu kako prosljeđujemo drugu strategiju kao argument! Ovo se zove kompozicija.st.tuples(st.text(), st.booleans())
: Generira torke s fiksnom strukturom.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Generira rječnike s određenim vrstama ključeva i vrijednosti.
- Vremenske:
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. To se može učiniti svjesnim vremenske zone.
- Razno:
st.booleans()
: GeneriraTrue
iliFalse
.st.just('constant_value')
: Uvijek generira istu pojedinačnu vrijednost. Korisno za sastavljanje složenih strategija.st.one_of(st.integers(), st.text())
: Generira vrijednost iz jedne od priloženih strategija.st.none()
: Generira samoNone
.
Kombiniranje i transformiranje strategija
Prava snaga Hypothesisa dolazi od njegove sposobnosti izgradnje složenih strategija od jednostavnijih.
Korištenje .map()
Metoda .map()
vam omogućuje da uzmete vrijednost iz jedne strategije i transformirate je u nešto drugo. Ovo je savršeno za stvaranje objekata vaših prilagođenih klasa.
# Jednostavna klasa podataka from dataclasses import dataclass @dataclass class User: user_id: int username: str # Strategija za generiranje User objekata user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
Korištenje .filter()
i assume()
Ponekad morate odbiti određene generirane vrijednosti. Na primjer, možda vam treba popis cijelih brojeva gdje zbroj nije nula. Možete koristiti .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Međutim, korištenje .filter()
može biti neučinkovito. Ako je uvjet često lažan, Hypothesis može provesti dugo vremena pokušavajući generirati valjani primjer. Bolji pristup je često korištenje assume()
unutar vaše testne funkcije:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... vaša testna logika ovdje ...
assume()
govori Hypothesisu: "Ako ovaj uvjet nije ispunjen, jednostavno odbacite ovaj primjer i pokušajte s novim." To je izravniji i često učinkovitiji način ograničavanja vaših testnih podataka.
Korištenje st.composite()
Za uistinu složeno generiranje podataka gdje jedna generirana vrijednost ovisi o drugoj, st.composite()
je alat koji vam je potreban. Omogućuje vam pisanje funkcije koja uzima posebnu funkciju draw
kao argument, koju možete koristiti za korak po korak izvlačenje vrijednosti iz drugih strategija.
Klasičan primjer je generiranje popisa i valjanog indeksa u tom popisu.
@st.composite def list_and_index(draw): # Prvo, nacrtajte neprazan popis my_list = draw(st.lists(st.integers(), min_size=1)) # Zatim, nacrtajte indeks za koji se jamči da je valjan za taj popis index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # Ovaj pristup je zajamčeno siguran zbog načina na koji smo izgradili strategiju element = my_list[index] assert element is not None # Jednostavna tvrdnja
Hypothesis u akciji: Stvarni scenariji
Primijenimo ove koncepte na realnije probleme s kojima se programeri softvera svakodnevno susreću.
Scenarij 1: Testiranje funkcije serijalizacije podataka
Zamislite funkciju koja serijalizira korisnički profil (rječnik) u URL-siguran niz i drugu koja ga deserializira. Ključno svojstvo je da postupak treba biti savršeno reverzibilan.
import json import base64 def serialize_profile(data: dict) -> str: """Serijalizira rječnik u URL-siguran base64 niz.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Deserializira niz natrag u rječnik.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Sada za test # Trebamo strategiju koja generira rječnike kompatibilne s JSON-om json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """Svojstvo: Deserializacija kodiranog profila trebala bi vratiti izvorni profil.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Ovaj pojedinačni test će bombardirati naše funkcije ogromnom raznolikošću podataka: prazni rječnici, rječnici s ugniježđenim popisima, rječnici s Unicode znakovima, rječnici s čudnim ključevima i još mnogo toga. To je daleko temeljitije od pisanja nekoliko ručnih primjera.
Scenarij 2: Testiranje algoritma sortiranja
Vratimo se našem primjeru sortiranja. Evo kako biste testirali svojstva koja smo definirali ranije.
from collections import Counter def my_buggy_sort(numbers): # Uvedimo suptilnu pogrešku: izostavlja duplikate return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Svojstvo 1: Izlaz je sortiran for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Svojstvo 2: Elementi su isti (ovo će pronaći pogrešku) assert Counter(numbers) == Counter(sorted_list) # Svojstvo 3: Funkcija je idempotentna assert my_buggy_sort(sorted_list) == sorted_list
Kada pokrenete ovaj test, Hypothesis će brzo pronaći neuspjeli primjer za Svojstvo 2, kao što je numbers=[0, 0]
. Naša funkcija vraća [0]
, a Counter([0, 0])
nije jednako Counter([0])
. Shrinker će osigurati da je primjer koji ne uspijeva što jednostavniji, čineći uzrok pogreške odmah očitim.
Scenarij 3: Testiranje stanja
Za objekte s unutarnjim stanjem koje se mijenja tijekom vremena (kao što je veza s bazom podataka, košarica za kupnju ili predmemorija), pronalaženje pogrešaka može biti nevjerojatno teško. Možda će biti potreban specifičan niz operacija da bi se pokrenula pogreška. Hypothesis pruža `RuleBasedStateMachine` upravo za tu svrhu.
Zamislite jednostavan API za pohranu ključ-vrijednost u memoriji:
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
Možemo modelirati njegovo ponašanje i testirati ga s automatom stanja:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() se koristi za prosljeđivanje podataka između pravila keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # Da biste pokrenuli test, jednostavno podrazredite iz stroja i unittest.TestCase # U pytest, možete jednostavno dodijeliti test klasi stroja TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis će sada izvršavati nasumične nizove operacija `set_key`, `delete_key`, `get_key` i `check_size`, neumoljivo pokušavajući pronaći niz koji uzrokuje da jedna od tvrdnji ne uspije. Provjerit će ponaša li se ispravno dobivanje izbrisanog ključa, je li veličina dosljedna nakon više postavljanja i brisanja i mnoge druge scenarije za koje možda ne biste pomislili da ih ručno testirate.
Najbolje prakse i napredni savjeti
- Baza podataka primjera: Hypothesis je pametan. Kada pronađe pogrešku, sprema primjer koji ne uspijeva u lokalni direktorij (
.hypothesis/
). Sljedeći put kada pokrenete svoje testove, prvo će ponoviti taj primjer koji ne uspijeva, dajući vam neposrednu povratnu informaciju da je pogreška još uvijek prisutna. Nakon što ga popravite, primjer se više ne ponavlja. - Kontroliranje izvršavanja testa s
@settings
: Možete kontrolirati mnoge aspekte pokretanja testa pomoću dekoratora@settings
. Možete povećati broj primjera, postaviti rok za koliko dugo se jedan primjer može izvoditi (za hvatanje beskonačnih petlji) i isključiti određene provjere ispravnosti.@settings(max_examples=500, deadline=1000) # Pokrenite 500 primjera, rok od 1 sekunde @given(...) ...
- Reproduciranje neuspjeha: Svako pokretanje Hypothesisa ispisuje vrijednost sjemena (npr.
@reproduce_failure('version', 'seed')
). Ako CI poslužitelj pronađe pogrešku koju ne možete reproducirati lokalno, možete koristiti ovaj dekorator s priloženim sjemenom kako biste prisilili Hypothesis da pokrene točno isti niz primjera. - Integracija s CI/CD: Hypothesis savršeno odgovara bilo kojem cjevovodu kontinuirane integracije. Njegova sposobnost pronalaženja nejasnih pogrešaka prije nego što dođu do proizvodnje čini ga neprocjenjivom sigurnosnom mrežom.
Promjena načina razmišljanja: Razmišljanje u svojstvima
Usvajanje Hypothesisa više je od učenja nove biblioteke; radi se o prihvaćanju novog načina razmišljanja o ispravnosti vašeg koda. Umjesto da pitate: "Koje ulaze trebam testirati?", počinjete pitati: "Koje su univerzalne istine o ovom kodu?"
Evo nekoliko pitanja koja će vas voditi kada pokušavate identificirati svojstva:
- Postoji li obrnuta operacija? (npr. serijaliziraj/deserijaliziraj, šifriraj/dešifriraj, komprimiraj/dekomprimiraj). Svojstvo je da bi izvođenje operacije i njezine inverzije trebalo dati izvorni unos.
- Je li operacija idempotentna? (npr.
abs(abs(x)) == abs(x)
). Primjena funkcije više od jednom trebala bi dati isti rezultat kao i primjena jednom. - Postoji li drugačiji, jednostavniji način izračunavanja istog rezultata? Možete testirati da vaša složena, optimizirana funkcija proizvodi isti izlaz kao i jednostavna, očito ispravna verzija (npr. testiranje vašeg otmjenog sortiranja u odnosu na Pythonov ugrađeni
sorted()
). - Što bi uvijek trebalo biti istinito o izlazu? (npr. izlaz funkcije `find_prime_factors` trebao bi sadržavati samo proste brojeve, a njihov umnožak bi trebao biti jednak ulazu).
- Kako se stanje mijenja? (Za testiranje stanja) Koje se invarijante moraju održavati nakon bilo koje valjane operacije? (npr. Broj stavki u košarici za kupnju nikada ne može biti negativan).
Zaključak: Nova razina povjerenja
Testiranje temeljeno na svojstvima s Hypothesisom ne zamjenjuje testiranje temeljeno na primjerima. Još uvijek su vam potrebni specifični, ručno napisani testovi za kritičnu poslovnu logiku i dobro razumljive zahtjeve (npr. "Korisnik iz zemlje X mora vidjeti cijenu Y").
Ono što Hypothesis pruža je moćan, automatiziran način istraživanja ponašanja vašeg koda i zaštite od nepredviđenih rubnih slučajeva. Djeluje kao neumorni partner, generirajući tisuće testova koji su raznolikiji i lukaviji od bilo čega što bi čovjek realno mogao napisati. Definiranjem temeljnih svojstava vašeg koda, stvarate robusnu specifikaciju u odnosu na koju Hypothesis može testirati, dajući vam novu razinu povjerenja u vaš softver.
Sljedeći put kada napišete funkciju, odvojite trenutak da razmislite izvan primjera. Zapitajte se: "Koja su pravila? Što uvijek mora biti istinito?" Zatim dopustite Hypothesisu da obavi težak posao pokušaja da ih razbije. Iznenadit ćete se onim što pronađe, a vaš kod će biti bolji zbog toga.