Pytest-parametrisering

Fuldført

Dekoratoren @pytest.mark.parametrize() lader én testfunktion køre flere gange med forskellige inputværdier. Brug det, når påstandslogikken er den samme, men eksemplerne varierer. Parametrisering kan reducere gentagelse og gør det muligt for pytest at rapportere hvert input som et eget testelement.

Dekoratøren tager som regel to nødvendige argumenter:

  • argnames: En komma-separeret streng, eller en liste eller tuple af strenge, med navnene på de argumenter, der skal overføres til testfunktionen. Eksempler omfatter "item", "test_input, expected", ["test_input", "expected"]og ("test_input", "expected").
  • argvalues: En iterabel af værdier eller parametersæt til brug for disse argumenter. Med ét argumentnavn sendes hvert element som én testværdi, selvom værdien er en tuple. Med flere argumentnavne skal hvert element give én værdi for hvert argumentnavn, normalt som en tuple, liste eller pytest.param(...) kald.

Det valgfrie ids argument lader dig tilpasse test-ID'et, som pytest bruger til at mærke hvert genereret testelement. Mere avancerede muligheder, såsom indirect og scope, er nyttige i tilfælde som parametriserede armaturer eller dyre ressourcer; du kan læse om dem i pytests parametriseringsvejledning og @pytest.mark.parametrize reference.

Note

Pytest sender parameterværdier til tests as-is; Den kopierer dem ikke. Hvis en test muterer en variabel parameterværdi, såsom en liste eller ordbog, kan denne mutation være synlig i senere genererede tilfælde, der modtager det samme objekt.

Hvornår skal du bruge parametrize?

To almindelige scenarier, hvor du måske vil bruge @pytest.mark.parametrize() dem, er:

  • Når en test looper over input, gentager den samme påstand
  • Når flere testfunktioner hævder den samme adfærd med forskellige input

Lad os gennemgå hvert eksempel først uden at bruge @pytest.mark.parametrize(), og derefter med det for at vise, hvordan det kan gøre tests lettere at vedligeholde og diagnosticere.

Til løkker

Her er et eksempel på en testfunktion med en for-løkke:

def test_string_is_digit():
    items = ["1", "10", "33"]
    for item in items:
        assert item.isdigit()

Dette mønster kan være svært at diagnosticere, når et input fejler:

  • Ufuldstændig feedback: Den første fejlende assertion stopper løkken, så senere værdier evalueres ikke i den kørsel.
  • Enkelt testresultat: Pytest rapporterer én indsamlet test i stedet for én testcase pr. input.
  • Gentagne genudsendelser: At rette den første fejl kan afsløre en ny fejl, først efter du har kørt testen igen.

Lad os ændre testen til at inkludere to elementer, der vil fejle assertionen:

def test_string_is_digit():
    items = ["No", "1", "10", "33", "Yes"]
    for item in items:
        assert item.isdigit()

Når testen køres, vises kun den første fejl, selvom der er to ugyldige elementer i listen. Kommandoeksemplerne bruger python -m pytest, som virker, når Python er tilgængelig som python. Hvis din shell bruger python3 på macOS eller Linux, eller py på Windows, så udskift den kommando.

python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________

    def test_string_is_digit():
        items = ["No", "1", "10", "33", "Yes"]
        for item in items:
>           assert item.isdigit()
E           AssertionError: assert False
E            +  where False = <built-in method isdigit of str object at 0x...>()
E            +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================

Pytests assertion-introspektionsoutput viser bound-metoden str.isdigit og strengværdien, der udløste fejlen. Sessionsheaders, platformdetaljer, hukommelsesadresser og kørtider kan variere, så eksemplerne udelader eller forkorter dem med ... og 0x.... Den relevante detalje er, at løkken stopper ved den første fejl ('No') og aldrig evaluerer 'Yes'.

Dette er et nyttigt tilfælde for @pytest.mark.parametrize(). Før vi opdaterer testen, lad os udforske en anden almindelig situation, der ikke involverer for løkker.

Test, der hævder den samme funktionsmåde

En gruppe tests, der fremsætter samme påstand, er også gode kandidater for @pytest.mark.parametrize(). Hvis den forrige test blev omskrevet med én test for hvert element, ville det give mulighed for bedre fejlrapportering, men det ville være gentagende:

def test_is_digit_1():
    assert "1".isdigit()

def test_is_digit_10():
    assert "10".isdigit()

def test_is_digit_33():
    assert "33".isdigit()

Disse tests er mere detaljerede, fordi en fejl kan være forbundet med et enkelt input. Selvom det kan virke usædvanligt at have flere lignende tests, er dette mønster almindeligt i produktionstestsuiter, der forsøger at rapportere fejl præcist.

Dog medfører gentagne tests følgende problemer:

  • Koden er gentagende, hvilket skaber en vedligeholdelsesbyrde.
  • Lignende funktioner er nemme at opdatere inkonsekvent.
  • At tilføje et nyt input kræver, at man kopierer en anden testkrop, så vigtige tilfælde kan blive overset.

Parametrisering bevarer rapporteringsfordelen ved separate testobjekter uden at kopiere den samme testkrop.

Sådan bruger du parametrize

Nu hvor du kender til nogle brugsscenarier for @pytest.mark.parametrize(), lad os opdatere testen, der brugte en for løkke med fejlende elementer.

Importer pytest, og anvend @pytest.mark.parametrize() derefter direkte over testfunktionen:

import pytest

@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
    assert item.isdigit()

Før vi kører testene, gennemgår vi ændringerne.

I dette eksempel modtager dekoratøren to argumenter. Det første argument, "item", navngiver det argument, som pytest fører ind i testfunktionen. Navnet skal matche parameteren item i test_string_is_digit(item). Det andet argument er listen over værdier, som pytest bruger til de genererede testcases.

Omfattende fejlrapportering

Bag kulisserne indsamler pytest ét testelement for hver værdi i parameterlisten. Det betyder, at beståede og dumpede sager rapporteres separat. Lad os se, hvad der sker, når vi kører testen:

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py F...F                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________

item = 'No'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________

item = 'Yes'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit

test_items.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================

Der er nogle bemærkelsesværdige punkter i testrapporten. Først rapporterer pytest fem indsamlede opgaver fra en enkelt testfunktion: tre beståede og to dumpede. Fejl rapporteres separat, inklusive inputværdien, der forårsagede hver fejl. Headeren test_string_is_digit[No] viser parametersættet, og parameteren lokalt item = 'No' vises over kildevisningen. Bound-metoden introspektionslinjerne (hukommelsesadresser vist som 0x...) bekræfter, hvilken værdi der udløste assertionsfejlen:

___________________________ test_string_is_digit[No] ___________________________

item = 'No'
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False

Det er svært at gå glip af den værdi, der forårsagede fejlen, med så mange steder, hvor den bliver rapporteret.

Brug det detaljerede outputflag

Når testene består, er kommandolinjerapporteringen som standard minimal. Sådan ville testen se ud efter en opdatering for at rette fejlene (fortsæt med at redigere den samme test_items.py , så import pytest den allerede er til stede):

@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
    assert item.isdigit()

Udførelse af testene giver minimal output:

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py .....                                                      [100%]

============================== 5 passed in 0.01s ===============================

At øge verbositeten viser hver genereret testcase og dets genererede parameter-ID:

python -m pytest -v test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py::test_string_is_digit[0] PASSED                            [ 20%]
test_items.py::test_string_is_digit[1] PASSED                            [ 40%]
test_items.py::test_string_is_digit[10] PASSED                           [ 60%]
test_items.py::test_string_is_digit[33] PASSED                           [ 80%]
test_items.py::test_string_is_digit[9] PASSED                            [100%]

============================== 5 passed in 0.01s ===============================

Sådan bruges flere argumentnavne

De eksempler, vi har set indtil nu, bruger ét argumentnavn: "item". Du kan også inkludere flere argumentnavne i det første argument. Når du bruger en streng, skal du adskille navnene med kommaer. Du kan også sende navnene som en liste eller tuple, såsom ["item", "attribute"] eller ("item", "attribute").

Et brugstilfælde for flere argumentnavne er at sende en inputværdi og den forventede værdi til test mod den. I det andet argument kræver hvert parametersæt én værdi for hvert argumentnavn. For eksempel, hvis argumentnavnene er "test_input, expected_value", kan argumentværdierne være [("3+5", 8), ("3*4", 12)].

Det næste eksempel bruger Python's hasattr()-funktion, som returnerer en boolesk værdi afhængigt af, om et objekt har en navngiven attribut:

>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False

Hver testcase kræver et objekt og et attributnavn, så vi kan bruge @pytest.mark.parametrize() det på følgende måde. Fortsæt i samme test_items.py fil, så import pytest det allerede er på plads:

@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

Dekoratoren @pytest.mark.parametrize() bruger stadig en enkelt streng til det første argument, men denne streng indeholder nu to argumentnavne adskilt af et komma. Navnene bliver argumenter for testfunktionen. I dette tilfælde er item de og attribute. En liste eller tuple af navne fungerer på samme måde.

Det andet argument er en liste med to parametersæt. Hvert parametersæt indeholder én item værdi og én attribute værdi. Hvis et parametersæt har for få eller for mange værdier for argumentnavnene, skaber pytest en samlingsfejl, før testtilfældene køres.

Pytest opretter et test-ID for hvert parametersæt. For simple værdier som tal, strenge, booleanske og Nonebruger pytest værdiens sædvanlige strengrepræsentation. For andre objekter falder pytest tilbage på et navn baseret på argumentnavnet og parametersættets nul-baserede indeks. I dette output bidrager den tomme streng med en tom ID-del, så stregstregseparatoren mellem de to ID'er stadig er synlig i [-format]. Fordi list() ikke har en simpel genereret ID, erstatter item1pytest :

python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
collected 2 items

test_items.py::test_attributes[-format] PASSED                           [ 50%]
test_items.py::test_attributes[item1-append] PASSED                      [100%]

============================== 2 passed in 0.01s ===============================

Hvis genererede ID'er er svære at læse, kan du sende det valgfrie ids argument videre eller indsætte en parameter pytest.param(..., id="name") for at give et klarere navn. Eksempel:

import pytest


@pytest.mark.parametrize(
    "item, attribute",
    [
        pytest.param("", "format", id="empty-string-format"),
        (list(), "append"),
    ],
)
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

Kaldet pytest.param("", "format", id="empty-string-format") leverer hele ID'et for det parametersæt og erstatter det auto-genererede -format ID. I verbose output hedder test_attributes[empty-string-format] de genererede tests og test_attributes[item1-append]. Clear IDs gør det lettere at scanne udførlige output- og fejlmeddelelser.