Python, dalle fondamenta Lezione 24 / 60

Property-based testing con hypothesis

Genera centinaia di casi di test in automatico. I pattern che trovano i bug che gli unit test si perdono.

Gli unit test che scrivi sono i casi a cui hai pensato. I bug in produzione sono i casi a cui non hai pensato. Il property-based testing colma quel divario lasciando che sia il framework di test a generare i casi per te, centinaia per ogni esecuzione, sbilanciati verso gli input che hanno più probabilità di rompere qualcosa.

In Python la libreria di property-based testing è hypothesis. È matura da anni, offre API pulite e si integra perfettamente con pytest. Questa lezione è la conoscenza operativa: come ragionare per proprietà, come scriverle, i pattern che trovano bug veri, e dove il property-based testing non vale il costo.

Example-based contro property-based

Un test pytest tradizionale è example-based: scegli input concreti e asserisci output concreti.

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

Funziona per i casi che hai scritto. Non dice niente su 1.005000000001, o -0.005, o 0.0, o float("nan"), a meno che tu non abbia scritto anche quelli.

Un test property-based asserisce una proprietà, qualcosa che dovrebbe valere per tutti gli input validi, e lascia che il framework la sondi:

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 lo esegue di default con cento float diversi, sbilanciati verso i casi limite (0.0, numeri molto piccoli, numeri con rappresentazioni binarie scomode). Se un input non rispetta la proprietà, hypothesis ti dice quale, e lo riduce al controesempio più piccolo che riesce a trovare.

Lo shrinking è la magia. Se un float casuale enorme fallisce, hypothesis non si limita a dire “ha fallito con 0.7281928281828”; sbozza l’input finché non trova quello più semplice che continua a fallire. Spesso ti restituisce qualcosa come 1e-300 o 0.0, e il bug diventa improvvisamente ovvio.

Le mosse di base

Installazione e import:

pip install hypothesis
from hypothesis import given, strategies as st

Una strategy è la descrizione di uno spazio di input. Hypothesis include strategie per ogni tipo Python comune:

st.integers()                       # qualsiasi int
st.integers(min_value=0)            # non negativi
st.floats(allow_nan=False)          # solo float finiti
st.text()                           # qualsiasi stringa unicode
st.text(alphabet="abc", max_size=5) # ristretta
st.lists(st.integers())             # lista di int
st.lists(st.integers(), min_size=1) # non vuota
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)

Combinare le strategie è solo composizione:

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

Per i tuoi tipi, @st.composite costruisce una strategia a partire da altre:

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

Una volta che hai una strategia per i tipi del tuo dominio, ogni test che ne ha bisogno si scrive in una riga.

Proprietà che vale la pena testare

L’abilità del property-based testing sta nell’identificare le proprietà. Alcuni pattern che saltano fuori ovunque:

Round-trip

Se il tuo codice codifica e decodifica, codificare e poi decodificare dovrebbe restituirti l’originale:

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

Cattura un numero sorprendente di bug nei serializer custom: tutto ciò che ha a che fare con quoting, encoding, caratteri speciali, container vuoti.

Idempotenza

Farlo due volte equivale a farlo una volta:

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

L’idempotenza è la proprietà naturale di qualsiasi funzione “normalizzante”: arrotondamento, ordinamento, deduplicazione, canonicalizzazione di URL, lowercasing.

Commutatività (dove dovrebbe valere)

Alcune operazioni dovrebbero dare la stessa risposta indipendentemente dall’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)

Monotonicità

L’output ordinato è non decrescente. Aggiungere a un contatore non lo fa mai diminuire. L’output di una “media” non supera mai il massimo degli input:

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

Invarianti sotto trasformazione

La lunghezza non cambia dopo una permutazione. Il totale non cambia se gli errori di arrotondamento vengono risommati. L’insieme degli ID cliente è preservato attraverso uno step ETL.

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

Un esempio lavorato: testare una funzione di arrotondamento prezzi

Ecco una funzione e le proprietà che scriverei per lei:

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

Le proprietà:

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

Tre proprietà, infiniti casi di test, e qualunque di esse fallisca ti dice che qualcosa di specifico non va. La prima volta che ho fatto girare una suite del genere su un codebase reale che gestiva soldi, hypothesis ha trovato un caso in cui 1e-308 non si arrotondava a zero per via di una stranezza di precisione nella conversione Decimal. Non un caso che avrei scritto a mano.

Stateful testing

Alcuni bug compaiono solo in sequenze di operazioni: la prima chiamata funziona, la seconda corrompe lo stato. Hypothesis lo gestisce con 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 genera sequenze casuali di chiamate a add_item e remove_last e verifica l’invariante dopo ognuna. Se il total() del carrello dovesse mai discostarsi dalla tua contabilità, hypothesis riduce la sequenza alla riproduzione più corta. Cattura bug delle macchine a stati che i test example-based non riescono a raggiungere.

Il costo, e come controllarlo

I test property-based sono più lenti dei test example-based. Un test con cento esecuzioni richiede più tempo reale di un test con un solo input. Per la maggior parte dei codebase va bene; la suite finisce comunque in pochi secondi. Per i test che colpiscono un database, una rete, o qualcosa di costoso, si regola con @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 riduce il numero di casi generati. deadline=None disabilita il limite di tempo per esempio di hypothesis, che inciampa sui test la cui velocità varia. C’è anche @settings(database=None) per disabilitare il database locale che hypothesis usa per ricordare i casi falliti tra esecuzioni: utile nei container CI, fastidioso in sviluppo locale.

Un pattern che uso: un profilo slow per la CI che gira con max_examples=500, e un profilo di default per lo sviluppo locale che usa il centinaio standard. La run di CI è più approfondita; quella locale è abbastanza veloce da farmi continuare a iterare.

Quando saltarlo

Il property-based testing non è sempre lo strumento giusto.

  • Codice puramente UI. “Quale proprietà dovrebbe avere questo componente React?” Di solito nessuna che valga la pena automatizzare.
  • Codice con forti dipendenze da rete o casualità. Se l’output della funzione dipende dalla risposta di un’API di terze parti, hypothesis non può generarla.
  • Script una tantum. L’investimento non si ripaga.
  • Quando la proprietà è più difficile da esprimere dell’implementazione. Se dovessi scrivere un’implementazione parallela solo per asserire la proprietà, non hai guadagnato nulla.

Lo sweet spot: funzioni pure su spazi di input ricchi. Parser, serializer, trasformazioni di dati, calcoli finanziari, algoritmi di ordinamento, tutto ciò che opera su dati fatti dagli utenti.

Bug veri che i property test catturano

Una breve lista di bug che ho personalmente catturato con hypothesis, nessuno dei quali era stato preso dai miei test example-based:

  • Un writer CSV che si rompeva sui valori contenenti sia una virgola sia una virgoletta.
  • Un parser di timestamp che gestiva male il confine tra ora standard e ora legale.
  • Un bug legato ai leap second in un calcolatore di durate.
  • Una routine di ordinamento instabile sui valori None, dove avevo dato per scontato che None non sarebbe mai apparso.
  • Un overflow di interi in un calcolo percentuale quando l’input era un intero a 64 bit vicino a 2^63.
  • Un mismatch di normalizzazione Unicode che faceva confrontare come diverse due stringhe che “sembravano uguali”.
  • Un merger di dict che perdeva chiavi quando la stessa chiave appariva con casing diverso in input diversi.

Tutti noiosi. Tutti spedibili come bug di produzione. Tutti catturati da una proprietà che ha richiesto cinque minuti per scriverla.

L’uso minimo consigliato

Se da questa lezione adotti una sola abitudine: per ogni funzione pura nel tuo codebase che prende una forma di input riconoscibile (una lista di numeri, una stringa con un alfabeto noto, una dataclass con campi documentati), scrivi un property test. Idempotenza, round-trip, monotonicità, scegli quello che si adatta. La soglia è bassa, il guadagno è alto, e la prima volta che hypothesis ti restituirà un input di una riga che fa crashare il tuo codice, sarai contento di averlo fatto.

Per la documentazione: i docs ufficiali di hypothesis su https://hypothesis.readthedocs.io/ sono il riferimento canonico, con un catalogo di strategie che vale la pena tenere nei preferiti.

Cerca