Python, de la zero Lecția 24 / 60

Property-based testing cu hypothesis

Generează automat sute de cazuri de test. Tiparele care prind bug-uri pe care testele unitare le ratează.

Testele unitare pe care le scrii sunt cazurile la care te-ai gândit. Bug-urile din producție sunt cazurile la care nu te-ai gândit. Property-based testing închide acea breșă lăsând framework-ul de testare să genereze el cazurile pentru tine, sute pe rulare de test, înclinate spre input-urile cu cea mai mare șansă să spargă lucrurile.

În Python, biblioteca pentru property-based testing este hypothesis. E matură de ani buni, vine cu API-uri curate și se integrează fără efort cu pytest. Lecția asta e cunoștința de lucru: cum să gândești în termeni de proprietăți, cum să le scrii, tiparele care prind bug-uri reale și unde property-based testing nu merită costul.

Example-based versus property-based

Un test pytest tradițional este example-based: alegi input-uri concrete și verifici output-uri concrete.

def test_round_price():
    assert round_price(1.005) == 1.00
    assert round_price(1.015) == 1.02

Asta funcționează pentru cazurile pe care le-ai notat. Nu spune nimic despre 1.005000000001, sau -0.005, sau 0.0, sau float("nan"), decât dacă le-ai notat și pe acelea.

Un test property-based verifică o proprietate, ceva care ar trebui să fie valabil pentru toate input-urile valide, și lasă framework-ul s-o sondeze:

from hypothesis import given, strategies as st

@given(st.floats(min_value=0, max_value=10_000, allow_nan=False))
def test_round_price_is_idempotent(amount: float) -> None:
    once = round_price(amount)
    twice = round_price(once)
    assert once == twice

Hypothesis rulează asta cu o sută de float-uri diferite by default, înclinate spre cazuri limită (0.0, numere foarte mici, numere cu reprezentări binare incomode). Dacă vreun input ratează proprietatea, hypothesis îți spune care, și îl reduce la cel mai mic contraexemplu pe care îl poate găsi.

Reducerea (shrinking) e magia. Dacă un float aleator uriaș eșuează, hypothesis nu spune doar „a eșuat cu 0.7281928281828”; ciopârțește input-ul până găsește cel mai simplu input care încă eșuează. Adesea primești înapoi ceva precum 1e-300 sau 0.0, iar bug-ul devine deodată evident.

Mișcările de bază

Instalare și import:

pip install hypothesis
from hypothesis import given, strategies as st

O strategie este o descriere a unui spațiu de input. Hypothesis vine cu strategii pentru fiecare tip Python comun:

st.integers()                       # any int
st.integers(min_value=0)            # non-negative
st.floats(allow_nan=False)          # finite floats only
st.text()                           # any unicode string
st.text(alphabet="abc", max_size=5) # restricted
st.lists(st.integers())             # list of ints
st.lists(st.integers(), min_size=1) # non-empty
st.dictionaries(st.text(), st.integers())
st.dates()                          # datetime.date
st.datetimes(timezones=st.timezones())
st.from_regex(r"^\d{3}-\d{4}$", fullmatch=True)

Combinarea strategiilor înseamnă pur și simplu compoziție:

st.tuples(st.text(), st.integers())
st.lists(st.tuples(st.text(), st.integers()), max_size=10)
st.one_of(st.integers(), st.text())

Pentru tipurile tale proprii, @st.composite construiește o strategie din altele:

from hypothesis import strategies as st
from dataclasses import dataclass

@dataclass(frozen=True)
class Order:
    id: int
    amount: float
    customer: str

@st.composite
def orders(draw: st.DrawFn) -> Order:
    return Order(
        id=draw(st.integers(min_value=1)),
        amount=draw(st.floats(min_value=0, allow_nan=False, allow_infinity=False)),
        customer=draw(st.text(min_size=1, max_size=50)),
    )

@given(orders())
def test_order_amount_non_negative(order: Order) -> None:
    assert order.amount >= 0

Odată ce ai o strategie pentru tipurile din domeniul tău, fiecare test care le folosește este un one-liner.

Proprietăți care merită testate

Abilitatea în property-based testing este să identifici proprietățile. Câteva tipare care apar peste tot:

Round-trip

Dacă codul tău codifică și decodifică, codificarea urmată de decodificare ar trebui să-ți dea înapoi originalul:

@given(st.dictionaries(st.text(), st.integers()))
def test_json_round_trip(data: dict[str, int]) -> None:
    assert json.loads(json.dumps(data)) == data

Asta prinde un număr surprinzător de bug-uri în serializatoare custom: tot ce ține de quoting, encoding, caractere speciale, containere goale.

Idempotență

A face de două ori egal cu a face o singură dată:

@given(st.text())
def test_strip_is_idempotent(s: str) -> None:
    assert s.strip().strip() == s.strip()

@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs: list[int]) -> None:
    assert sorted(sorted(xs)) == sorted(xs)

Idempotența este proprietatea naturală a oricărei funcții „de normalizare”: rotunjire, sortare, deduplicare, canonicalizare URL-uri, lowercase.

Comutativitate (acolo unde ar trebui să fie valabilă)

Unele operații ar trebui să dea același răspuns indiferent de ordine:

@given(st.lists(st.integers()), st.lists(st.integers()))
def test_set_union_is_commutative(a: list[int], b: list[int]) -> None:
    assert set(a) | set(b) == set(b) | set(a)

Monotonie

Output-ul sortat este nedescrescător. Adăugarea la un counter nu îl scade niciodată. Output-ul unei „medii” nu depășește niciodată input-ul maxim:

@given(st.lists(st.integers(), min_size=1))
def test_sort_is_non_decreasing(xs: list[int]) -> None:
    s = sorted(xs)
    assert all(a <= b for a, b in zip(s, s[1:], strict=False))

Invarianți sub transformare

Lungimea nu se schimbă după o permutare. Totalul nu se schimbă după ce erorile de rotunjire sunt însumate înapoi. Mulțimea ID-urilor de clienți este conservată într-un pas ETL.

@given(st.lists(st.integers()))
def test_reverse_preserves_length(xs: list[int]) -> None:
    assert len(list(reversed(xs))) == len(xs)

Un exemplu lucrat: testarea unei funcții de rotunjire a prețului

Iată o funcție și proprietățile pe care le-aș scrie pentru ea:

from decimal import Decimal, ROUND_HALF_EVEN

def round_price(amount: float) -> float:
    """Round to the nearest cent, banker's rounding."""
    return float(
        Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
    )

Proprietățile:

from hypothesis import given, strategies as st

prices = st.floats(min_value=0, max_value=1_000_000, allow_nan=False, allow_infinity=False)

@given(prices)
def test_round_price_is_idempotent(amount: float) -> None:
    once = round_price(amount)
    assert round_price(once) == once

@given(prices)
def test_round_price_close_to_input(amount: float) -> None:
    assert abs(round_price(amount) - amount) <= 0.005 + 1e-9

@given(prices)
def test_round_price_two_decimals(amount: float) -> None:
    rounded = round_price(amount)
    assert round(rounded * 100) == rounded * 100

Trei proprietăți, cazuri de test infinite, iar dacă oricare dintre ele eșuează îți spune că ceva specific nu e bine. Când am rulat prima dată acest gen de suită pe un codebase real care manipulează bani, hypothesis a găsit un caz în care 1e-308 nu se rotunjea la zero din cauza unei ciudățenii de precizie în conversia Decimal. Nu un caz pe care l-aș fi scris de mână.

Stateful testing

Unele bug-uri apar doar în secvențe de operații: primul apel funcționează, al doilea corupe starea. Hypothesis tratează asta cu RuleBasedStateMachine:

from hypothesis.stateful import RuleBasedStateMachine, rule, invariant

class CartMachine(RuleBasedStateMachine):
    def __init__(self) -> None:
        super().__init__()
        self.cart = Cart()
        self.expected_total = 0.0

    @rule(amount=st.floats(min_value=0, max_value=100))
    def add_item(self, amount: float) -> None:
        self.cart.add(amount)
        self.expected_total += amount

    @rule()
    def remove_last(self) -> None:
        if self.cart.items:
            removed = self.cart.remove_last()
            self.expected_total -= removed

    @invariant()
    def total_matches(self) -> None:
        assert abs(self.cart.total() - self.expected_total) < 1e-6

TestCart = CartMachine.TestCase

Hypothesis generează secvențe aleatorii de apeluri add_item și remove_last și verifică invariantul după fiecare. Dacă total() al coșului se abate vreodată de la contabilitatea ta, hypothesis reduce secvența la cea mai scurtă reproducere. Asta prinde bug-uri de state-machine pe care testele example-based nu le pot atinge.

Costul, și cum îl controlezi

Testele property-based sunt mai lente decât testele example-based. Un test cu o sută de rulări durează mai mult decât un test cu un singur input. Pentru majoritatea codebase-urilor asta e bine; suita încă termină în secunde. Pentru teste care lovesc o bază de date, o rețea, sau orice e scump, reglezi cu @settings:

from hypothesis import given, settings, strategies as st

@settings(max_examples=20, deadline=None)
@given(st.text())
def test_slow_thing(s: str) -> None:
    ...

max_examples reduce numărul de cazuri generate. deadline=None dezactivează limita de timp per exemplu a hypothesis, care se declanșează la teste cu viteze variabile. Există și @settings(database=None) pentru a dezactiva baza de date locală pe care hypothesis o folosește pentru a-și aminti cazurile care eșuează între rulări, utilă în containerele CI, enervantă în dezvoltarea locală.

Un tipar pe care îl folosesc: un profil slow pentru CI care rulează max_examples=500, și un profil default pentru dezvoltarea locală care folosește suta standard. Rularea CI e mai amănunțită; rularea locală e suficient de rapidă să mă țină iterând.

Când să o sari

Property-based testing nu este întotdeauna unealta potrivită.

  • Cod pur de UI. „Ce proprietate ar trebui să aibă această componentă React?” De obicei niciuna care să merite automatizată.
  • Cod cu dependențe puternice de rețea sau de aleator. Dacă output-ul funcției depinde de răspunsul de la un API third-party, hypothesis nu îl poate genera.
  • Scripturi one-off. Investiția nu se recuperează.
  • Când proprietatea e mai greu de exprimat decât implementarea. Dacă ar trebui să scrii o implementare paralelă doar ca să verifici proprietatea, n-ai câștigat nimic.

Punctul dulce: funcții pure peste spații bogate de input. Parsere, serializatoare, transformări de date, calcule financiare, algoritmi de sortare, orice operează pe date modelate de utilizator.

Bug-uri reale prinse de teste property-based

O listă scurtă de bug-uri pe care le-am prins personal cu hypothesis, niciunul dintre care nu era acoperit de testele mele example-based:

  • Un writer de CSV care se rupea la valori care conțineau și o virgulă, și o ghilimea.
  • Un parser de timestamp-uri care gestiona greșit granița dintre ora standard și ora de vară.
  • Un bug legat de leap-second într-un calculator de durate.
  • O rutină de sortare care era instabilă pe valori None acolo unde presupusesem că None n-o să apară niciodată.
  • Un overflow pe întregi într-un calcul procentual când input-ul era un întreg pe 64 de biți aproape de 2^63.
  • O nepotrivire de normalizare Unicode care făcea ca două string-uri care „arătau la fel” să compare ca neegale.
  • Un merger de dict care pierdea chei când aceeași cheie apărea cu litere diferite în input-uri diferite.

Toate plictisitoare. Toate livrabile ca bug-uri de producție. Toate prinse de o proprietate care a luat cinci minute să fie scrisă.

Utilizarea minimă recomandată

Dacă adopți o singură obișnuință din lecția asta: pentru fiecare funcție pură din codebase-ul tău care primește o formă de input recognoscibilă (o listă de numere, un string cu un alfabet cunoscut, un dataclass cu câmpuri documentate), scrie un test de proprietate. Idempotență, round-trip, monotonie, alege oricare se potrivește. Bara e joasă, recompensa e mare, iar prima dată când hypothesis îți întoarce un input de o linie care îți crapă codul, vei fi bucuros că ai făcut-o.

Pentru documentație: docs-urile proprii ale hypothesis la https://hypothesis.readthedocs.io/ sunt referința canonică, cu un catalog de strategii care merită pus la favorite.

Caută