Opdag property-baseret test med Pythons Hypothesis-bibliotek. Gå ud over eksemplerbaserede tests for at finde grænsetilfælde og opbyg mere robust og pålidelig software.
Ud over enhedstests: Et dybt dyk ned i Property-baseret test med Pythons Hypothesis
I softwareudviklingens verden er testning grundlaget for kvalitet. I årtier har det dominerende paradigme været eksempelbaseret testning. Vi udformer omhyggeligt input, definerer de forventede output og skriver påstande for at verificere, at vores kode opfører sig som planlagt. Denne tilgang, der findes i rammer som unittest
og pytest
, er kraftfuld og essentiel. Men hvad hvis jeg fortalte dig, at der er en supplerende tilgang, der kan afdække fejl, du aldrig havde tænkt på at lede efter?
Velkommen til verdenen af property-baseret testning, et paradigme, der flytter fokus fra at teste specifikke eksempler til at verificere generelle egenskaber ved din kode. Og i Python-økosystemet er den ubestridte mester for denne tilgang et bibliotek kaldet Hypothesis.
Denne omfattende guide vil tage dig fra en komplet begynder til en selvsikker udøver af property-baseret testning med Hypothesis. Vi vil udforske kernebegreberne, dykke ned i praktiske eksempler og lære, hvordan man integrerer dette kraftfulde værktøj i din daglige udviklingsworkflow for at opbygge mere robust, pålidelig og fejlbestandig software.
Hvad er Property-baseret testning? Et skift i tankegangen
For at forstå Hypothesis skal vi først forstå den grundlæggende idé bag property-baseret testning. Lad os sammenligne det med den traditionelle eksempelbaserede testning, vi alle kender.
Eksempelbaseret testning: Den velkendte sti
Forestil dig, at du har skrevet en brugerdefineret sorteringsfunktion, my_sort()
. Med eksempelbaseret testning ville din tankeproces være:
- "Lad os teste det med en simpel, ordnet liste." ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "Hvad med en omvendt-ordnet liste?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "Hvad med en tom liste?" ->
assert my_sort([]) == []
- "En liste med dubletter?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "Og en liste med negative tal?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
Dette er effektivt, men det har en grundlæggende begrænsning: du tester kun de tilfælde, du kan komme i tanke om. Dine tests er kun så gode som din fantasi. Du kan gå glip af grænsetilfælde, der involverer meget store tal, flydende-komma-unøjagtigheder, specifikke unicode-tegn eller komplekse kombinationer af data, der fører til uventet adfærd.
Property-baseret testning: Tænkning i invarianter
Property-baseret testning vender manuskriptet. I stedet for at give specifikke eksempler definerer du egenskaberne, eller invarianterne, af din funktion - regler, der skal være sande for ethvert gyldigt input. For vores my_sort()
-funktion kan disse egenskaber være:
- Outputtet er sorteret: For enhver liste af tal er hvert element i outputlisten mindre end eller lig med det, der følger det.
- Outputtet indeholder de samme elementer som inputtet: Den sorterede liste er bare en permutation af den originale liste; ingen elementer tilføjes eller mistes.
- Funktionen er idempotent: Sortering af en allerede sorteret liste bør ikke ændre den. Det vil sige,
my_sort(my_sort(some_list)) == my_sort(some_list)
.
Med denne tilgang skriver du ikke testdataene. Du skriver reglerne. Du lader derefter en ramme, som Hypothesis, generere hundredvis eller tusindvis af tilfældige, forskellige og ofte snedige input for at prøve at bevise, at dine egenskaber er forkerte. Hvis den finder et input, der bryder en egenskab, har den fundet en fejl.
Introduktion til Hypothesis: Din automatiserede testdatagenerator
Hypothesis er det førende bibliotek til property-baseret testning for Python. Det tager de egenskaber, du definerer, og gør det hårde arbejde med at generere testdata til at udfordre dem. Det er ikke bare en tilfældig datagenerator; det er et intelligent og kraftfuldt værktøj designet til at finde fejl effektivt.
Nøglefunktioner i Hypothesis
- Automatisk testcasesgenerering: Du definerer *formen* af de data, du har brug for (f.eks. "en liste af heltal", "en streng, der kun indeholder bogstaver", "en datetime i fremtiden"), og Hypothesis genererer en bred vifte af eksempler, der overholder den form.
- Intelligent formindskelse: Dette er den magiske funktion. NĂĄr Hypothesis finder en fejlslagen testcase (f.eks. en liste af 50 komplekse tal, der fĂĄr din sorteringsfunktion til at crashe), rapporterer den ikke bare den massive liste. Den forenkler intelligent og automatisk inputtet for at finde det mindste mulige eksempel, der stadig forĂĄrsager fejlen. I stedet for en liste med 50 elementer kan den rapportere, at fejlen opstĂĄr med bare
[inf, nan]
. Dette gør fejlfinding utrolig hurtig og effektiv. - Problemfri integration: Hypothesis integreres perfekt med populære testrammer som
pytest
ogunittest
. Du kan tilføje property-baserede tests sammen med dine eksisterende eksempelbaserede tests uden at ændre dit workflow. - Rigt bibliotek af strategier: Det leveres med en stor samling af indbyggede "strategier" til at generere alt fra simple heltal og strenge til komplekse, indlejrede datastrukturer, tidszone-bevidste datetimes og endda NumPy-arrays.
- Statfuld testning: For mere komplekse systemer kan Hypothesis teste sekvenser af handlinger for at finde fejl i statsovergange, noget der er notorisk vanskeligt med eksempelbaseret testning.
Kom godt i gang: Din første Hypothesis-test
Lad os få hænderne beskidte. Den bedste måde at forstå Hypothesis på er at se det i aktion.
Installation
Først skal du installere Hypothesis og din testkører efter eget valg (vi bruger pytest
). Det er sĂĄ simpelt som:
pip install pytest hypothesis
Et simpelt eksempel: En absolut værdi-funktion
Lad os overveje en simpel funktion, der formodes at beregne den absolutte værdi af et tal. En lidt buggy implementering kan se sådan ud:
# i en fil med navnet `my_math.py` def custom_abs(x): """En brugerdefineret implementering af den absolutte værdi-funktion.""" if x < 0: return -x return x
Lad os nu skrive en testfil, test_my_math.py
. Først den traditionelle pytest
-tilgang:
# test_my_math.py (Eksempelbaseret) 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
Disse tests består. Vores funktion ser korrekt ud baseret på disse eksempler. Men lad os nu skrive en property-baseret test med Hypothesis. Hvad er en kerneegenskab ved den absolutte værdi-funktion? Resultatet bør aldrig være negativt.
# test_my_math.py (Property-baseret med Hypothesis) 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): """Egenskab: Den absolutte værdi af et heltal er altid >= 0.""" assert custom_abs(x) >= 0
Lad os bryde dette ned:
from hypothesis import given, strategies as st
: Vi importerer de nødvendige komponenter.given
er en dekoratør, der gør en almindelig testfunktion til en property-baseret test.strategies
er modulet, hvor vi finder vores datageneratorer.@given(st.integers())
: Dette er kernen i testen.@given
-dekoratoren fortæller Hypothesis at køre denne testfunktion flere gange. For hver kørsel vil den generere en værdi ved hjælp af den angivne strategi,st.integers()
, og sende den som argumentetx
til vores testfunktion.assert custom_abs(x) >= 0
: Dette er vores ejendom. Vi hævder, at uanset hvilket heltalx
Hypothesis drømmer op, skal resultatet af vores funktion være større end eller lig med nul.
Når du kører dette med pytest
, vil det sandsynligvis bestå for mange værdier. Hypothesis vil prøve 0, -1, 1, store positive tal, store negative tal og mere. Vores simple funktion håndterer alle disse korrekt. Lad os nu prøve en anden strategi for at se, om vi kan finde en svaghed.
# Lad os teste med flydende kommatal @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
Hvis du kører dette, vil Hypothesis hurtigt finde en fejlslagen case!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis opdagede, at vores funktion, nĂĄr den fĂĄr float('nan')
(Not a Number), returnerer nan
. PĂĄstanden nan >= 0
er falsk. Vi har lige fundet en subtil fejl, som vi sandsynligvis ikke ville have tænkt på at teste manuelt. Vi kunne rette vores funktion til at håndtere dette tilfælde, måske ved at hæve en ValueError
eller returnere en bestemt værdi.
Endnu bedre, hvad hvis fejlen var med et meget specifikt float? Hypothesis's shrinker ville have taget et stort, komplekst fejlslagent tal og reduceret det til den enkleste mulige version, der stadig udløser fejlen.
Strategiens styrke: Udformning af dine testdata
Strategier er hjertet i Hypothesis. De er opskrifter til at generere data. Biblioteket indeholder en stor samling af indbyggede strategier, og du kan kombinere og tilpasse dem til at generere stort set enhver datastruktur, du kan forestille dig.
Almindelige indbyggede strategier
- Numerisk:
st.integers(min_value=0, max_value=1000)
: Genererer heltal, eventuelt inden for et bestemt interval.st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: Genererer floats, med finkornet kontrol over specielle værdier.st.fractions()
,st.decimals()
- Tekst:
st.text(min_size=1, max_size=50)
: Genererer unicode-strenge af en bestemt længde.st.text(alphabet='abcdef0123456789')
: Genererer strenge fra et specifikt tegnsæt (f.eks. til hex-koder).st.characters()
: Genererer individuelle tegn.
- Samlinger:
st.lists(st.integers(), min_size=1)
: Genererer lister, hvor hvert element er et heltal. Bemærk, hvordan vi sender en anden strategi som et argument! Dette kaldes komposition.st.tuples(st.text(), st.booleans())
: Genererer tupler med en fast struktur.st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: Genererer ordbøger med specificerede nøgle- og værdi-typer.
- Temporal:
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
. Disse kan gøres tidszone-bevidste.
- Diverse:
st.booleans()
: GenerererTrue
ellerFalse
.st.just('constant_value')
: Genererer altid den samme enkelte værdi. Nyttig til at sammensætte komplekse strategier.st.one_of(st.integers(), st.text())
: Genererer en værdi fra en af de angivne strategier.st.none()
: Genererer kunNone
.
Kombinering og transformering af strategier
Den virkelige styrke ved Hypothesis kommer fra dens evne til at opbygge komplekse strategier fra enklere.
Brug af .map()
.map()
-metoden lader dig tage en værdi fra en strategi og transformere den til noget andet. Dette er perfekt til at skabe objekter af dine brugerdefinerede klasser.
# En simpel dataklasse from dataclasses import dataclass @dataclass class User: user_id: int username: str # En strategi til at generere User-objekter 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()
Brug af .filter()
og assume()
Nogle gange skal du afvise visse genererede værdier. For eksempel har du måske brug for en liste af heltal, hvor summen ikke er nul. Du kan bruge .filter()
:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
Men brugen af .filter()
kan være ineffektiv. Hvis betingelsen ofte er falsk, kan Hypothesis bruge lang tid på at forsøge at generere et gyldigt eksempel. En bedre tilgang er ofte at bruge assume()
inde i din testfunktion:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... din testlogik her ...
assume()
fortæller Hypothesis: "Hvis denne betingelse ikke er opfyldt, skal du bare kassere dette eksempel og prøve et nyt." Det er en mere direkte og ofte mere performant måde at begrænse dine testdata på.
Brug af st.composite()
For virkelig kompleks datagenerering, hvor en genereret værdi afhænger af en anden, er st.composite()
det værktøj, du har brug for. Det giver dig mulighed for at skrive en funktion, der tager en speciel draw
-funktion som et argument, som du kan bruge til at trække værdier fra andre strategier trin for trin.
Et klassisk eksempel er at generere en liste og et gyldigt indeks til den liste.
@st.composite def list_and_index(draw): # Først skal du trække en ikke-tom liste my_list = draw(st.lists(st.integers(), min_size=1)) # Træk derefter et indeks, der garanteret er gyldigt for den liste 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 # Denne adgang er garanteret sikker på grund af, hvordan vi har bygget strategien element = my_list[index] assert element is not None # En simpel påstand
Hypothesis i aktion: Real-World scenarier
Lad os anvende disse begreber pĂĄ mere realistiske problemer, som softwareudviklere stĂĄr over for hver dag.
Scenarie 1: Test af en dataserialisering funktion
Forestil dig en funktion, der serialiserer en brugerprofil (en ordbog) til en URL-sikker streng og en anden, der deserialiserer den. En nøgleegenskab er, at processen skal være perfekt reversibel.
import json import base64 def serialize_profile(data: dict) -> str: """Serialiserer en ordbog til en URL-sikker base64-streng.""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """Deserialiserer en streng tilbage til en ordbog.""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # Nu til testen # Vi har brug for en strategi, der genererer JSON-kompatible ordbøger 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): """Egenskab: Deserialisering af en kodet profil skal returnere den originale profil.""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
Denne enkelte test vil hamre vores funktioner med et massivt udvalg af data: tomme ordbøger, ordbøger med indlejrede lister, ordbøger med unicode-tegn, ordbøger med mærkelige nøgler og mere. Det er langt mere grundigt end at skrive et par manuelle eksempler.
Scenarie 2: Test af en sorteringsalgoritme
Lad os genbesøge vores sorteringseksempel. Her er hvordan du ville teste de egenskaber, vi definerede tidligere.
from collections import Counter def my_buggy_sort(numbers): # Lad os introducere en subtil fejl: den dropper dubletter return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # Egenskab 1: Outputtet er sorteret for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # Egenskab 2: Elementerne er de samme (dette vil finde fejlen) assert Counter(numbers) == Counter(sorted_list) # Egenskab 3: Funktionen er idempotent assert my_buggy_sort(sorted_list) == sorted_list
Når du kører denne test, vil Hypothesis hurtigt finde et fejlslagent eksempel for Egenskab 2, såsom numbers=[0, 0]
. Vores funktion returnerer [0]
, og Counter([0, 0])
er ikke lig med Counter([0])
. Shrinkeren vil sikre, at det fejlslagne eksempel er så simpelt som muligt, hvilket gør fejlens årsag umiddelbart åbenlys.
Scenarie 3: Statfuld testning
For objekter med intern tilstand, der ændrer sig over tid (som en databaseforbindelse, en indkøbskurv eller en cache), kan det være utroligt vanskeligt at finde fejl. En specifik sekvens af operationer kan være påkrævet for at udløse en fejl. Hypothesis leverer `RuleBasedStateMachine` til netop dette formål.
Forestil dig en simpel API til en in-memory nøgle-værdi-butik:
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)
Vi kan modellere dens adfærd og teste den med en state machine:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # Bundle() bruges til at videregive data mellem regler 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() # For at køre testen skal du blot underklasse fra maskinen og unittest.TestCase # I pytest kan du blot tildele testen til maskinklassen TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesis vil nu udføre tilfældige sekvenser af `set_key`, `delete_key`, `get_key` og `check_size` operationer, ubarmhjertigt forsøge at finde en sekvens, der får en af påstandene til at fejle. Den vil kontrollere, om hentning af en slettet nøgle opfører sig korrekt, om størrelsen er konsistent efter flere sæt og sletninger, og mange andre scenarier, du måske ikke tænker på at teste manuelt.
Best Practices og avancerede tips
- Eksempeldatabasen: Hypothesis er smart. NĂĄr den finder en fejl, gemmer den det fejlslagne eksempel i en lokal mappe (
.hypothesis/
). Næste gang du kører dine tests, vil den afspille det fejlslagne eksempel først, hvilket giver dig øjeblikkelig feedback om, at fejlen stadig er til stede. Når du har rettet det, afspilles eksemplet ikke længere. - Styring af testkørsel med
@settings
: Du kan styre mange aspekter af testkørslen ved hjælp af@settings
-dekoratoren. Du kan øge antallet af eksempler, indstille en deadline for, hvor længe et enkelt eksempel kan køre (for at fange uendelige løkker) og slå visse sundhedstjek fra.@settings(max_examples=500, deadline=1000) # Kør 500 eksempler, 1-sekunds deadline @given(...) ...
- Reproduktion af fejl: Hver Hypothesis-kørsel udskriver en seed-værdi (f.eks.
@reproduce_failure('version', 'seed')
). Hvis en CI-server finder en fejl, som du ikke kan reproducere lokalt, kan du bruge denne dekoratør med den angivne seed for at tvinge Hypothesis til at køre den nøjagtigt samme sekvens af eksempler. - Integration med CI/CD: Hypothesis er et perfekt match til enhver kontinuerlig integrationspipeline. Dens evne til at finde obskure fejl, før de når produktion, gør den til et uvurderligt sikkerhedsnet.
Tankegangsskiftet: Tænkning i egenskaber
At adoptere Hypothesis er mere end bare at lære et nyt bibliotek; det handler om at omfavne en ny måde at tænke på din kodes korrekthed. I stedet for at spørge: "Hvilke input skal jeg teste?", begynder du at spørge: "Hvad er de universelle sandheder om denne kode?"
Her er nogle spørgsmål til at guide dig, når du forsøger at identificere egenskaber:
- Er der en omvendt operation? (f.eks. serialiser/deserialiser, krypter/dekrypter, komprimer/dekomprimer). Egenskaben er, at udførelse af operationen og dens omvendte skal give det originale input.
- Er operationen idempotent? (f.eks.
abs(abs(x)) == abs(x)
). Anvendelse af funktionen mere end én gang skal give det samme resultat som at anvende den én gang. - Er der en anden, enklere måde at beregne det samme resultat på? Du kan teste, at din komplekse, optimerede funktion producerer det samme output som en simpel, åbenlyst korrekt version (f.eks. test af din smarte sortering mod Pythons indbyggede
sorted()
). - Hvad skal altid være sandt om outputtet? (f.eks. skal outputtet af en `find_prime_factors`-funktion kun indeholde primtal, og deres produkt skal være lig med inputtet).
- Hvordan ændres tilstanden? (For statfuld testning) Hvilke invarianter skal opretholdes efter enhver gyldig operation? (f.eks. Antallet af varer i en indkøbskurv kan aldrig være negativt).
Konklusion: Et nyt niveau af tillid
Property-baseret testning med Hypothesis erstatter ikke eksempelbaseret testning. Du har stadig brug for specifikke, hĂĄndskrevne tests til kritisk forretningslogik og velkendte krav (f.eks. "En bruger fra land X skal se pris Y").
Hvad Hypothesis giver, er en kraftfuld, automatiseret måde at udforske din kodes adfærd og beskytte mod uforudsete grænsetilfælde. Det fungerer som en utrættelig partner, der genererer tusindvis af tests, der er mere forskellige og snedige, end noget menneske realistisk kunne skrive. Ved at definere de grundlæggende egenskaber ved din kode skaber du en robust specifikation, som Hypothesis kan teste imod, hvilket giver dig et nyt niveau af tillid til din software.
Næste gang du skriver en funktion, skal du tage et øjeblik til at tænke ud over eksemplerne. Spørg dig selv: "Hvad er reglerne? Hvad skal altid være sandt?" Lad derefter Hypothesis gøre det hårde arbejde med at prøve at bryde dem. Du vil blive overrasket over, hvad den finder, og din kode vil være bedre for det.