Kas yra property-based testing ir kodėl turėtum apie tai žinoti
Turbūt visi esame susidūrę su situacija, kai rašai testus ir jaučiesi tarsi šachmatininkas, bandantis numatyti visus galimus ėjimus. Parašai kelis unit testus su konkrečiais įvesties pavyzdžiais, visi žali, CI/CD pipeline’as laimingas, o po savaitės production’e kažkas sprogsta dėl edge case’o, apie kurį net nepagalvojai. Skamba pažįstamai?
Property-based testing – tai visiškai kitoks požiūris į testavimą. Vietoj to, kad rašytum dešimtis testų su konkrečiomis reikšmėmis („kai x=5, turėtų grąžinti 10”), tu aprašai bendrąsias tavo kodo savybes ir leidi įrankiui sugeneruoti šimtus ar tūkstančius atsitiktinių įvesties variantų. Python ekosistemoje šiam tikslui puikiai tarnauja Hypothesis biblioteka.
Įsivaizduok, kad vietoj to, jog pats bandytum sugalvoti visus galimus kraštutinių atvejų variantus, turi asistentą, kuris be perstojo mėto į tavo funkciją įvairiausius duomenis – tuščius sąrašus, neigiamus skaičius, Unicode simbolius, None reikšmes, milžiniškus skaičius. Ir viskas automatiškai.
Hypothesis įdiegimas ir pirmieji žingsniai
Pradėti naudoti Hypothesis yra paprasčiau nei įsidiegti dar vieną JavaScript framework’ą (ir tikrai mažiau skausminga). Paprastas pip install ir esi pasiruošęs:
„`bash
pip install hypothesis
„`
Pažiūrėkime į paprastą pavyzdį. Tarkime, turime funkciją, kuri apverčia sąrašą:
„`python
def reverse_list(items):
return items[::-1]
„`
Tradicinis unit testas atrodytų maždaug taip:
„`python
def test_reverse_list():
assert reverse_list([1, 2, 3]) == [3, 2, 1]
assert reverse_list([]) == []
assert reverse_list([‘a’]) == [‘a’]
„`
O dabar pažiūrėkime, kaip tai atrodytų su Hypothesis:
„`python
from hypothesis import given
from hypothesis.strategies import lists, integers
@given(lists(integers()))
def test_reverse_twice_is_identity(items):
assert reverse_list(reverse_list(items)) == items
„`
Matai skirtumą? Vietoj konkrečių pavyzdžių, mes aprašome **savybę** – jei apversi sąrašą du kartus, turėtum gauti originalą. Hypothesis automatiškai sugeneruos dešimtis skirtingų sąrašų variantų ir patikrina šią savybę kiekvienam iš jų.
Strategies – kaip pasakyti Hypothesis, kokių duomenų nori
Hypothesis strategies yra tarsi receptai, pagal kuriuos generuojami testiniai duomenys. Biblioteka turi strategies beveik viskam – nuo primityvių tipų iki sudėtingų duomenų struktūrų.
Štai keletas dažniausiai naudojamų:
„`python
from hypothesis import strategies as st
# Primityvūs tipai
st.integers() # Bet kokie sveikieji skaičiai
st.integers(min_value=0, max_value=100) # Su ribomis
st.floats()
st.text() # Unicode tekstas
st.booleans()
# Kolekcijos
st.lists(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
st.tuples(st.integers(), st.text()) # Fiksuoto ilgio
# Sudėtingesni variantai
st.emails() # Generuoja validžius email adresus
st.uuids()
st.datetimes()
„`
Galima ir kombinuoti strategies. Pavyzdžiui, jei nori sąrašo, kuris tikrai nebus tuščias:
„`python
st.lists(st.integers(), min_size=1)
„`
Arba jei reikia vieno iš kelių variantų:
„`python
st.one_of(st.integers(), st.text(), st.none())
„`
Realybėje dažnai reikia sugeneruoti sudėtingesnius objektus. Tarkime, turi dataclass’ę:
„`python
from dataclasses import dataclass
from hypothesis.strategies import builds
@dataclass
class User:
username: str
age: int
email: str
user_strategy = builds(
User,
username=st.text(min_size=3, max_size=20),
age=st.integers(min_value=18, max_value=120),
email=st.emails()
)
@given(user_strategy)
def test_user_validation(user):
assert len(user.username) >= 3
assert user.age >= 18
„`
Realūs pavyzdžiai iš praktikos
Teorija teorija, bet pažiūrėkime, kaip tai veikia realiuose scenarijuose. Tarkime, rašai API, kuris priima JSON ir atlieka tam tikrus transformacijas.
„`python
import json
from hypothesis import given, assume
from hypothesis.strategies import dictionaries, text, integers, recursive
def serialize_and_deserialize(data):
„””Funkcija, kuri serializuoja į JSON ir deserializuoja atgal”””
json_string = json.dumps(data)
return json.loads(json_string)
# Rekursyvus strategy JSON-like struktūroms
json_strategy = recursive(
base=integers() | text() | booleans() | none(),
extend=lambda children: lists(children) | dictionaries(text(), children)
)
@given(json_strategy)
def test_json_roundtrip(data):
„””JSON serialization turėtų būti reversible”””
result = serialize_and_deserialize(data)
assert result == data
„`
Arba kitas praktinis pavyzdys – testavimas funkcijos, kuri skaičiuoja kainą su nuolaida:
„`python
def calculate_discounted_price(price, discount_percent):
if discount_percent < 0 or discount_percent > 100:
raise ValueError(„Nuolaida turi būti tarp 0 ir 100”)
return price * (1 – discount_percent / 100)
@given(
price=st.floats(min_value=0.01, max_value=1000000),
discount=st.floats(min_value=0, max_value=100)
)
def test_discount_properties(price, discount):
result = calculate_discounted_price(price, discount)
# Savybė 1: rezultatas negali būti neigiamas
assert result >= 0
# Savybė 2: rezultatas negali būti didesnis už originalią kainą
assert result <= price
# Savybė 3: su 0% nuolaida kaina lieka ta pati
if discount == 0:
assert abs(result - price) < 0.01
```
Kada Hypothesis tikrai išgelbsti kailį
Yra keletas situacijų, kur property-based testing tiesiog spindi. Pirmiausia – **parsers ir serializers**. Jei rašai kodą, kuris konvertuoja duomenis iš vieno formato į kitą, property testing padės rasti kraštutinių atvejų, apie kuriuos net nesusimąstei.
Pavyzdžiui, testuojant URL parser’į:
„`python
from urllib.parse import urlparse, urlunparse
@given(st.from_regex(r’https?://[a-zA-Z0-9.-]+\.[a-z]{2,}(/.*)?’, fullmatch=True))
def test_url_parsing_roundtrip(url):
parsed = urlparse(url)
reconstructed = urlunparse(parsed)
# Turėtų būti ekvivalentiškas originalui
assert urlparse(reconstructed) == parsed
„`
Kita sritis – **matematinės operacijos ir algoritmai**. Jei implementuoji sorting algoritmą, vietoj to kad testuotum su [3, 1, 2], gali aprašyti savybes:
„`python
@given(st.lists(st.integers()))
def test_sort_properties(items):
sorted_items = my_sort_function(items)
# Savybė 1: rezultatas turi būti surūšiuotas
assert all(sorted_items[i] <= sorted_items[i+1]
for i in range(len(sorted_items)-1))
# Savybė 2: turi turėti tuos pačius elementus
assert sorted(items) == sorted(sorted_items)
# Savybė 3: ilgis turi būti tas pats
assert len(items) == len(sorted_items)
```
**State machine testing** – dar viena galinga Hypothesis funkcija. Jei turi sistemą su būsenomis (pvz., shopping cart), gali aprašyti galimus veiksmus ir leisti Hypothesis generuoti atsitiktines veiksmų sekas:
```python
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
class ShoppingCartMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cart = ShoppingCart()
self.items_added = []
@rule(item=st.text(), quantity=st.integers(min_value=1, max_value=10))
def add_item(self, item, quantity):
self.cart.add(item, quantity)
self.items_added.append((item, quantity))
@rule()
def clear_cart(self):
self.cart.clear()
self.items_added = []
@invariant()
def cart_not_negative(self):
assert self.cart.total() >= 0
@invariant()
def item_count_matches(self):
assert len(self.cart.items) == len(set(i[0] for i in self.items_added))
„`
Dažniausios kliūtys ir kaip jų išvengti
Naudojant Hypothesis, neišvengiamai susidursi su keliais iššūkiais. Pirmas – **per lėti testai**. Hypothesis pagal nutylėjimą generuoja 100 pavyzdžių kiekvienam testui. Jei tavo funkcija lėta, testai gali užtrukti amžinybę.
Sprendimas – naudok `@settings` dekoratorių:
„`python
from hypothesis import settings
@settings(max_examples=20, deadline=500) # deadline in milliseconds
@given(st.lists(st.integers()))
def test_something_slow(items):
# tavo testas
pass
„`
Antra problema – **flaky tests**. Kartais Hypothesis randa edge case’ą, kuris failina testą, bet ne dėl kodo klaidos, o dėl to, kad tavo testas netinkamai aprašo savybę. Pavyzdžiui:
„`python
@given(st.floats())
def test_bad_float_comparison(x):
# Blogai – floating point aritmetika nėra tiksli!
assert (x + 1) – 1 == x # Gali failinti dėl precision issues
„`
Geriau:
„`python
import math
@given(st.floats(allow_nan=False, allow_infinity=False))
def test_good_float_comparison(x):
assert math.isclose((x + 1) – 1, x, rel_tol=1e-9)
„`
Trečia – **per daug generuojamų invalid inputs**. Kartais nori testuoti tik tam tikrus duomenų poaibius. Naudok `assume()`:
„`python
from hypothesis import assume
@given(st.integers(), st.integers())
def test_division(a, b):
assume(b != 0) # Praleidžia testą, jei b yra 0
result = a / b
assert result * b == a # (su float precision caveats)
„`
Bet atsargiai – jei per daug `assume()` iškvietimų atmeta per daug pavyzdžių, Hypothesis pradės skųstis. Geriau naudoti strategies su apribojimais:
„`python
@given(st.integers(), st.integers(min_value=1)) # Geriau!
def test_division(a, b):
result = a / b
# …
„`
Integravimas į CI/CD ir kasdienį workflow
Hypothesis puikiai integruojasi su pytest ir kitais testing framework’ais. Tiesiog rašyk testus kaip įprastai, ir jie veiks:
„`bash
pytest test_my_module.py
„`
Viena iš geriausių Hypothesis savybių – **example database**. Kai Hypothesis randa failinantį pavyzdį, jis išsaugo jį `.hypothesis/examples/` direktorijoje. Kitą kartą paleidus testus, pirmiausia bus patikrinti visi anksčiau rasti probleminiai atvejai. Tai reiškia, kad jei sufiksini bug’ą, testas užtikrins, kad jis nesugrįš.
Svarbu įtraukti `.hypothesis/` į `.gitignore`, nebent nori dalintis rastais edge case’ais su komanda (kartais tai prasminga):
„`
# .gitignore
.hypothesis/*
!.hypothesis/examples/ # Palikti examples, jei nori
„`
CI/CD aplinkoje gali norėti kontroliuoti, kiek laiko testai gali užtrukti:
„`python
from hypothesis import settings, Verbosity
import os
# Lokaliame dev aplinkoje – daugiau pavyzdžių
# CI aplinkoje – greičiau, bet vis tiek pakankamai
if os.getenv(‘CI’):
settings.register_profile(„ci”, max_examples=50, deadline=1000)
else:
settings.register_profile(„dev”, max_examples=200, deadline=None)
settings.load_profile(os.getenv(‘HYPOTHESIS_PROFILE’, ‘dev’))
„`
Dar vienas patarimas – naudok `–hypothesis-show-statistics` flag’ą su pytest, kad pamatytum, kaip Hypothesis generuoja duomenis:
„`bash
pytest –hypothesis-show-statistics
„`
Tai padės suprasti, ar tavo strategies efektyvūs, ar gal per daug laiko švaistomai atmestų pavyzdžių generavimui.
Pažangesnės technikos ir triukai
Kai jau įsijausi į Hypothesis, yra keletas pažangesnių technikų, kurios gali būti naudingos.
**Composite strategies** – kai reikia sudėtingesnės logikos generuojant duomenis:
„`python
from hypothesis.strategies import composite
@composite
def valid_user_data(draw):
age = draw(st.integers(min_value=18, max_value=100))
# Email domenas priklauso nuo amžiaus
domain = „university.edu” if age < 25 else "company.com"
username = draw(st.text(alphabet=st.characters(whitelist_categories=('Lu', 'Ll')), min_size=3, max_size=10))
return {
'username': username,
'age': age,
'email': f"{username.lower()}@{domain}"
}
@given(valid_user_data())
def test_user_registration(user_data):
# testas su realistiškesniais duomenimis
pass
```
**Shrinking** – viena įspūdingiausių Hypothesis savybių. Kai randamas failinantis testas, Hypothesis automatiškai bando "susiaurinti" pavyzdį iki minimalaus, kuris vis dar failina. Vietoj to, kad gautum error su 1000 elementų sąrašu, gausi su 2 elementų sąrašu, kuris rodo tą pačią problemą.
Galima kontroliuoti shrinking procesą:
```python
@given(st.lists(st.integers(), min_size=1).filter(lambda x: len(x) > 0))
@settings(max_examples=1000, suppress_health_check=[HealthCheck.filter_too_much])
def test_with_custom_shrinking(items):
# …
pass
„`
**Targeted property testing** – eksperimentinė funkcija, kuri leidžia Hypothesis „mokytis” iš ankstesnių paleidimų ir generuoti duomenis, kurie labiau linkę rasti bug’us:
„`python
from hypothesis import target
@given(st.integers())
def test_with_targeting(x):
target(float(abs(x))) # Skatina didesnių reikšmių generavimą
# tavo testas
„`
Kodėl verta investuoti laiką į property testing
Grįžkime prie esmės. Property-based testing nėra silver bullet, ir jis neturėtų pakeisti visų tavo unit testų. Bet kai naudoji jį tinkamose vietose, rezultatai gali būti įspūdingi.
Aš pats esu radęs bug’ų, apie kuriuos niekada nebūčiau pagalvojęs rašydamas tradicinius testus. Unicode simboliai, kurie sulaužo string parsing’ą. Neigiami skaičiai, kur tikėjausi tik teigiamų. Tuščios kolekcijos, kurios triggerino null pointer exceptions. Hypothesis randa visa tai automatiškai.
Taip, pradžioje reikia laiko permąstyti testavimą iš „pavyzdžių” į „savybes”. Bet kai įpranti, pradedi matyti savo kodą kitaip. Pradedi galvoti apie invariantus, apie tai, kas **visada** turėtų būti tiesa, nepriklausomai nuo įvesties.
Jei dirbi su kritine logika – finansiniais skaičiavimais, saugumo funkcijomis, duomenų transformacijomis – property testing turėtų būti tavo įrankių arsenale. Net jei nenaudosi jo visur, tiems kritiniams kodo gabalams jis suteiks pasitikėjimo, kurio negausi iš kelių rankiniu būdu parašytų testų.
Pradėk mažai – pasiimk vieną ar dvi funkcijas, kurios tau kelia nerimą, ir parašyk jiems property testus. Pažiūrėk, ką Hypothesis ras. Dažniausiai būsi maloniai nustebintas (arba nemaloniai, priklausomai nuo to, kiek bug’ų ras). Bet tikrai būsi protingesnis dėl to.
