Markers, skip en xfail — welke tests draaien waar
Vertel pytest wat elke test nodig heeft en wat je ervan verwacht met skip, skipif, importorskip en xfail; wat strict=True, raises= en XPASS betekenen; registreer je eigen markers en selecteer met -m; plus geparametriseerde fixtures en factory-fixtures.
Hoe wil je leren?
Het probleem dat we oplossen
Een klein project, shop, met één module en twee testbestanden. money.py:
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)test_money.py controleert het met gewone waarden. test_export.py leest prijzen uit YAML, waarvoor het externe pakket yaml nodig is:
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0Op een machine waar yaml niet is geïnstalleerd, zegt pytest -q:
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sLees de laatste twee regels. Interrupted. De twee goede tests in test_money.py draaiden nooit. Eén bestand dat niet op deze machine kon draaien nam de hele run mee naar beneden.
Dat is de werkelijke situatie in elk project dat lang genoeg bestaat. Sommige tests hebben een bibliotheek nodig die optioneel is. Sommige hebben alleen zin op Linux. Sommige hebben een API-sleutel die alleen CI heeft. Sommige beschrijven een bug die iedereen kent en die nog niemand heeft gerepareerd. Sommige duren een minuut, en je wilt niet elke keer een minuut wachten wanneer je een bestand opslaat.
Die tests verwijderen is verkeerd, en ze uitcommentariëren ook. Wat je wilt is pytest vertellen wat elke test nodig heeft en wat je ervan verwacht, en pytest laten beslissen, run na run, wat het ermee doet. Het hulpmiddel daarvoor is de marker.
Aan het einde van dit hoofdstuk kun je
- Een test altijd overslaan, onder een voorwaarde, of vanuit de test zelf, en de reden zichtbaar maken
- Een heel bestand overslaan wanneer een optioneel pakket ontbreekt, met
pytest.importorskip - Een bekende bug markeren met
xfail, en die preciseren metstrict=Trueenraises= - De rapportageletters
s,xenXlezen, en details vragen met-rs,-rx,-rA - Je eigen markers bedenken, ze registreren, en typfouten vangen met
--strict-markers - Tests selecteren met
-m "not slow"en-m "slow and db" - Een fixture schrijven die elke test meerdere keren laat draaien, en een fixture die een functie teruggeeft
Voorkennis: mocking.
Voordat je de test schrijft
Markers gaan niet over wat een test controleert — eerdere hoofdstukken behandelden dat. Ze gaan over twee vragen die je beantwoordt voordat de test geschreven is, en die de meeste mensen pas beantwoorden nadat een run rood is geworden om de verkeerde reden.
1. Wat heeft deze test nodig om überhaupt te draaien? Een pakket dat geen harde afhankelijkheid is, een besturingssysteem, een Python-versie, een omgevingsvariabele met een geheim, een database, een netwerk, veel tijd. Elk hiervan is een voorwaarde. Als die ontbreekt, heeft de test niets te melden: ze is niet geslaagd, en ze is niet gefaald.
2. Wat verwacht ik vandaag van haar? Normaal gesproken: slagen. Maar als de code een bekende bug heeft en jij de test schrijft die het juiste gedrag beschrijft, verwacht je dat ze faalt — en op één bepaalde manier faalt.
De antwoorden bepalen de marker, dus schrijf ze eerst op. Voor het project shop ziet het plan er zo uit:
| Geval | Invoer / situatie | Heeft nodig | Verwacht | Marker | |---|---|---|---|---| | gewone prijs | "1,250.50" | niets | 1250.5 | geen | | YAML-export | "pen: '15.00'" | het pakket yaml | 15.0 waar geïnstalleerd | pytest.importorskip("yaml") | | POSIX-pad | "data", "prices.csv" | geen Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | live wisselkoersen | de echte API | RATES_API_KEY ingesteld | een koers, alleen waar de sleutel bestaat | pytest.skip() in de test | | valutasymbool | "$1,250" | niets | 1250.0 — vandaag ValueError (bug #42) | xfail(raises=ValueError, strict=True) | | veel prijzen | 1000 strings | een seconde of meer | correcte som | slow (eigen marker) |
Lees de tabel per kolom. De kolom Heeft nodig levert skips op. De kolom Verwacht levert xfails op. De tijd die het kost levert een eigen marker op waarmee je kiest wat er draait.
Wat je op orde moet hebben voordat hiervan iets werkt:
- een virtuele omgeving met
pytestgeïnstalleerd, enmoney.pyimporteerbaar vanaf de tests (draai vanaf de projectroot, zoals in eerdere hoofdstukken); - een
pyproject.tomlin de projectroot, want daar worden eigen markers geregistreerd; - de bug ergens opgeschreven met een nummer, zodat
reason=ernaar kan verwijzen.
En wat je niet moet markeren:
- Sla een test niet over omdat ze faalt en je niet weet waarom. Een skip verbergt de mislukking voor elke volgende run. Repareer haar, of begrijp haar goed genoeg om
xfail(raises=...)met een reden te schrijven. - Markeer een flaky test niet als
xfail. Een test die soms slaagt, verschijnt de ene dag alsXen de andere alsx, en metstrict=Truefaalt ze willekeurig. Flakiness is een bug in de test; repareer die in plaats daarvan. - Label niet alles als
slow. Als de meeste tests bij elke run worden gedeselecteerd, controleert de dagelijkse run niets. Label de enkele tests die echt seconden kosten. - Test niet of pytest correct overslaat. Je test
money.py, niet pytest. De marker is configuratie; de rapportage van de run is bewijs genoeg dat het werkt.
De rest van dit hoofdstuk voert de tabel uit, rij voor rij.
skip — niet deze test, en hier is waarom
Eerst de manier waarop het meestal gedaan wordt voordat iemand markers kent — een vroege return:
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sGeslaagd. De test controleerde niets, en de rapportage zegt dat ze geslaagd is. Op een laptop zonder sleutel is dat een leugen die bij elke run verteld wordt, en het groene stipje is niet te onderscheiden van een echt. De oplossing is niet de voorwaarde vermijden, maar eerlijk rapporteren — als een skip.
De eenvoudigste marker is een decorator die zegt "voer dit niet uit", met een reden erbij:
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09sVier tests, vier verschillende manieren van beslissen:
@pytest.mark.skip(reason=...)— altijd overgeslagen. Het lichaam draait nooit.@pytest.mark.skipif(condition, reason=...)— overgeslagen wanneer de voorwaarde waar is. De voorwaarde wordt geëvalueerd wanneer het bestand wordt verzameld. Deze run was op Linux, dustest_posix_pathdraaide en slaagde; de Python was 3.12, dustest_new_featurewerd overgeslagen.pytest.skip("...")aangeroepen in de test — de beslissing valt terwijl de test draait. Gebruik dit wanneer je halverwege pas te weten komt, bijvoorbeeld na het bekijken van de omgeving of van wat een fixture retourneerde.
Elke s in de voortgangsregel is een overgeslagen test. Een skip is geen mislukking: de run is groen.
Maar de redenen zijn nergens te zien. Standaard vat pytest alleen mislukkingen en errors samen. Vraag skips met -rs:
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sDat is waarom de reason ertoe doet. Over drie maanden vertelt alleen "skipped" niemand of de test weer aangezet kan worden.
importorskip — wanneer een heel bestand een pakket nodig heeft
Terug naar test_export.py. Vervang import yaml door dit:
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08spytest.importorskip("yaml") probeert de import. Lukt het, dan geeft het de module terug — daarom wordt het aan yaml toegewezen. Mislukt het, dan wordt het hele bestand overgeslagen, met de importfout als reden. De andere twee tests draaien nu wel. Installeer yaml en hetzelfde bestand draait volledig, zonder wijziging.
xfail — een bug die je kent
Een skip zegt "deze test kan hier niet draaien". Er is een tweede, andere uitspraak: "deze test draait, en ze moet falen, omdat de code een bekende bug heeft".
parse_price heeft er een. Hij handelt een valutasymbool niet af:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx:
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sAnders dan een overgeslagen test draait een xfail-test wel. Ze faalde, zoals voorspeld, dus ze verschijnt als een kleine x en de run blijft groen. De test is nu een vastgelegd document van de bug, in de suite, in plaats van een notitie in iemands hoofd.
Waarom niet gewoon skip? Omdat een overgeslagen test nooit draait, en dus nooit iets kan vertellen — niet wanneer de bug gerepareerd is, en niet wanneer de code verandert zodat ze op een nieuwe manier faalt. Een xfail-test draait elke keer, en het resultaat wordt vergeleken met wat je voorspelde. Skip is voor "kan hier niet draaien"; xfail is voor "draait, en ik weet wat ze gaat zeggen".
Iemand repareert nu bug #42 en verandert money.py zodat de $ wordt verwijderd:
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12sEen hoofdletter X, XPASS: "verwacht te falen, maar geslaagd". En de run is nog steeds groen — de exit-code is 0. Niemand kijkt naar een groene run, dus de marker blijft zitten, en de test blijft een bug claimen die niet meer bestaat.
strict=True — maak goed nieuws luid
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sMet strict=True is een test die onverwacht slaagt een mislukking. Dat klinkt tegenstrijdig, en het is precies wat je wilt: de persoon die de bug repareerde wordt gevraagd de marker te verwijderen, en de test verandert in een gewone test die de reparatie vanaf dat moment bewaakt. Wil je dit voor elke xfail in het project, zet dan xfail_strict = true in de pytest-configuratie.
raises= — falen om de juiste reden
Een xfail zonder argumenten accepteert elke mislukking. Daaronder vallen ook mislukkingen die je niet bedoelde:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09sBeide tests hebben dezelfde typfout, parse_prise. test_loose slikte de NameError en rapporteerde een rustige x — ze zou voor altijd "verwacht te falen" blijven, zonder ooit parse_price te testen. test_precise verklaarde welke mislukking ze verwacht, de ValueError die float("$1250") opwerpt, dus elke andere exception wordt als een echte mislukking gerapporteerd. Corrigeer de typfout en beide worden x.
De rapportageletters
De voortgangsregel gebruikt één teken per test, en -r accepteert dezelfde letters om te kiezen wat er in de samenvatting komt:
| Letter | Betekening | Getoond in de samenvatting met | |---|---|---| | . | geslaagd | -rp | | F | gefaald | -rf (standaard aan) | | E | error in setup of teardown | -rE (standaard aan) | | s | overgeslagen | -rs | | x | xfailed — gefaald zoals verwacht | -rx | | X | xpassed — onverwacht geslaagd | -rX |
Letters combineren: -rsx toont skips en xfails samen. -rA toont alle ervan, ook geslaagde tests:
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sJe eigen markers
skip en xfail veranderen wat pytest met een test doet. Een marker kan ook een gewoon label zijn, met een naam die je zelf bedenkt, en verandert niets totdat je hem gebruikt om tests te selecteren. Zeg dat sommige tests langzaam zijn, en sommige een database nodig hebben:
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09sAlle vier geslaagd, en pytest klaagt over elk gebruik. pytest.mark.<anything> wordt geaccepteerd — het attribuut wordt ter plekke aangemaakt — dus pytest kan slow niet onderscheiden van een typfout in iets anders. Het vraagt je de namen te declareren die je bedoelt.
Markers registreren
De lijst gaat in pyproject.toml, één string per marker, de naam vóór de dubbele punt en een beschrijving erna:
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11sDe waarschuwingen zijn weg, en pytest --markers toont ze nu voor de volgende persoon die aan het project komt:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseSelecteren met -m
Nu de labels zichzelf terugbetalen. -m accepteert een expressie over markernamen, met and, or, not en haakjes:
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07sGedeselecteerd is niet overgeslagen. Een overgeslagen test werd gekozen en weigerde daarna te draaien; een gedeselecteerde test werd nooit gekozen. -m "not slow" is de run die je bij elke opslag doet; de volledige run is voor CI en vóór je pusht.
--strict-markers — een typfout wordt een error
De waarschuwing is makkelijk te missen. Hier is een test die iemand later toevoegde, met de marker verkeerd gespeld:
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07sEr werd om -m "not slow" gevraagd, en de run kostte nog steeds een seconde: de verkeerd gespelde test is niet slow, dus hij draaide. Met --strict-markers stopt een niet-geregistreerde marker de verzameling in plaats daarvan:
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sJe wilt die vlag niet elke keer intypen, dus zet hem in addopts:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — een heel bestand markeren
Wanneer elke test in een bestand de database nodig heeft, is het decoreren van elke test herhaling. Een variabele op moduleniveau met de naam pytestmark geldt voor elke test in het bestand:
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07sDe naam moet exact pytestmark zijn. Het kan ook een lijst zijn, pytestmark = [pytest.mark.db, pytest.mark.slow].
Markers op één geval van parametrize
Een marker op een geparametriseerde test geldt voor elk geval. Om één geval te markeren, verpak je het in pytest.param(..., marks=...):
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================De bekende bug leeft naast de gevallen die werken, in dezelfde tabel, en de dag dat hij gerepareerd is verwijder je één pytest.param-wrapper.
Verder gaan: fixtures die variëren, en fixtures die bouwen
Twee fixturepatronen pasten niet in de fixturehoofdstukken, en markers zijn een goed moment ervoor, omdat beide de tests die je net leerde selecteren vermenigvuldigen of vormgeven.
Een geparametriseerde fixture
parametrize varieert de invoer van één test. Soms wil je dat elke test die een fixture gebruikt één keer draait per waarde van die fixture. Geef @pytest.fixture een params-lijst, en lees de huidige waarde uit request.param:
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================Twee tests, drie waarden, zes runs. request is een ingebouwde fixture die de test beschrijft die wordt voorbereid; request.param bestaat alleen wanneer de fixture params heeft. ids geeft elke waarde een naam in de rapportage — zonder dat bouwt pytest een id uit de waarde zelf, wat bij lange of niet-weergeefbare waarden lastig leest. De lijst mag ook pytest.param(...)-items met marks= bevatten, net als in parametrize.
Waar dit zijn plaats verdient: dezelfde set tests tegen meerdere backends, meerdere bestandsformaten, meerdere valuta's. Voeg één waarde toe aan params, en elke test die de fixture gebruikt dekt die ook af.
Een factory-fixture
Een fixture kan geen argumenten van de test krijgen; de test noemt haar alleen. Wat doe je dan wanneer een test twee objecten nodig heeft, op verschillende manieren gebouwd? Geef een functie terug:
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sDe fixture draait nog steeds één keer per test, en krijgt nog steeds zijn eigen fixtures (tmp_path hier, die ook voor het verwijderen van de bestanden zorgt). Wat ze overdraagt is _make, en de test roept hem zo vaak aan als hij wil, met welke argumenten hij wil. De naam make_... is de gebruikelijke conventie, en hij vertelt de lezer in één oogopslag dat deze fixture een functie is om aan te roepen, geen kant-en-klare waarde.
Een compleet voorbeeld
Alles samen in één project. pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py, met dezelfde money.py — nog steeds met bug #42:
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")De dagelijkse run, met redenen:
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sAlleen de trage, en de offline run die zowel de trage als de netwerktests deselecteert:
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08sTel ze op: één fixture met drie waarden geeft drie runs van test_share_is_positive, drie gevallen van test_share, één trage test en één netwerktest — acht in totaal, en dat is waar 1 passed, 7 deselected op uitkomt.
En elk resultaat vertelt de waarheid. De netwerktest doet niet alsof ze zonder sleutel slaagt; ze zegt dat ze overgeslagen is en waarom. Bug #42 zit in de suite, en omdat hij strict is met raises=ValueError, verandert de run op de dag dat hij gerepareerd is in het rood en vraagt hij de marker te verwijderen.
Wanneer het misgaat
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? De marker is niet geregistreerd. Voeg hem toe aan markers in pyproject.toml. Als de naam echt een typfout is, repareer hem — en voeg --strict-markers toe aan addopts zodat de volgende een error is in plaats van een waarschuwing.
`'slwo' not found in markers configuration option` Dit is --strict-markers dat zijn werk doet: het bestand gebruikt een marker die niet in de lijst staat. Repareer de spelling, of registreer de nieuwe naam.
8 deselected en no tests ran, exit-code 5 -m kwam met niets overeen. Een verkeerd gespelde naam in een -m-expressie is geen error, zelfs niet met --strict-markers — -m sloww selecteert simpelweg geen test. Vergelijk de naam met pytest --markers.
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input De -m-expressie is onvolledig. Zet de hele expressie tussen aanhalingstekens, en zorg dat elke and/or aan beide kanten iets heeft.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") zonder reason=. Een booleaanse voorwaarde zegt niets over waarom, dus pytest staat op een reden.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") werd aangeroepen op het topniveau van een bestand. Voor een ontbrekend pakket gebruik je pytest.importorskip; anders schrijf je pytest.skip("...", allow_module_level=True), of gebruik je een skipif in pytestmark.
[XPASS(strict)] bug #42 Goed nieuws, gerapporteerd als een mislukking: de bug is gerepareerd. Verwijder de xfail-marker en behoud de test.
Een xfail-test die x blijft, zelfs nadat de bug gerepareerd is Ze faalt om een andere reden — vaak een typfout in de test zelf. Voeg raises= toe met de exception die je echt verwacht, en al het andere verschijnt als een echte mislukking.
AttributeError: 'SubRequest' object has no attribute 'param' De fixture leest request.param, maar heeft geen params=-lijst, en is ook op geen andere manier geparametriseerd. Voeg params=[...] toe aan @pytest.fixture.
fixture 'name' not found, dat naar een fixture verwijst Er is een fixture geschreven als def make_user(name):, in de hoop dat de test name zou doorgeven. Fixture-argumenten zijn andere fixtures, dus pytest zocht er een met de naam name. Maak er een factory van: een interne functie die name aanneemt, geretourneerd door de fixture.
Stap 4 van 6 — Voorspellen
Test je begrip
Wat zijn de voortgangsregel en de laatste regel van pytest -q? (Onthoud dat round(2.675, 2) 2.67 oplevert.)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- A..x 2 passed, 1 xfailed
- Bs.X 1 passed, 1 skipped, 1 xpassed
- Cs.F 1 passed, 1 skipped, 1 failed
- Ds.x 1 passed, 1 skipped, 1 xfailed
slow is geregistreerd in pyproject.toml, en --strict-markers staat uit. Je voert pytest -m "not slow" uit, en de run duurt nog steeds vijf seconden. Waarom?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"` voert alleen de trage tests uit
- BDe marker is verkeerd gespeld als `slwo`, dus de test is niet `slow` en wordt geselecteerd; pytest geeft alleen een waarschuwing
- C`time.sleep` zorgt ervoor dat pytest markers negeert
- D`-m` werkt alleen met ingebouwde markers zoals `skip`
Bug #12 is in feite al gerepareerd, dus de assert is waar. Wat rapporteert pytest -q?
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed, exit code 0
- B`F` — FAILED met `[XPASS(strict)]`, exit code 1
- C`s` — de test wordt overgeslagen omdat hij gemarkeerd is
- D`x` — 1 xfailed, exit code 0
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Neem een testsuite die je al hebt — van een eerder hoofdstuk, of uit je eigen werk — en geef haar een eerlijke set markers:
- Registreer twee eigen markers in
pyproject.toml(bijvoorbeeldslowennetwork), en zet--strict-markersaan inaddopts. - Markeer minstens één test met elk. Gebruik
time.sleep(1)om er een trage te verzinnen als je er geen hebt. - Voeg een test toe die een omgevingsvariabele nodig heeft, en sla haar vanuit de test over met
pytest.skipwanneer de variabele ontbreekt. - Vind, of verzin, één bug in je code. Schrijf de test die zou moeten slagen, en markeer haar als
xfailmetstrict=Trueen een preciezeraises=. - Voeg één fixture toe met
paramsenids, gebruikt door minstens twee tests.
Voer daarna uit, en sla de output van elke op:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
Tot slot twee experimenten. Spel een van je markers verkeerd en voer een keer uit zonder en een keer met --strict-markers. Repareer daarna de bug uit stap 4 en voer opnieuw uit. Lees wat pytest in elk geval zegt — die twee berichten zijn hetgeen een suite vol markers eerlijk houdt, en je herkent ze makkelijker de tweede keer als je ze zelf een keer hebt veroorzaakt.
Oplossing
Eén oplossing, met money.py uit het project shop. De bug voor stap 4 is een nieuwe, bug #51: een lege cel in een spreadsheet komt binnen als "", en zou als 0.0 moeten tellen — maar float("") werpt een ValueError op.
pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py:
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.0De drie runs:
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08sHet eerste experiment: @pytest.mark.slow verkeerd gespeld als @pytest.mark.slwo. Zonder --strict-markers (hier uitgeschakeld met -o addopts=""), kost de run nog steeds een seconde, omdat de "trage" test niet werd gedeselecteerd:
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sMet de vlag aan weigert de run te starten:
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sHet tweede experiment: bug #51 gerepareerd in money.py:
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sVerwijder de xfail-regel en de test slaagt vanaf dan als een gewone test.
De reden achter elke beslissing:
--strict-markersinaddopts, niet op de opdrachtregel. Een vangnet dat je moet onthouden aan te zetten, is geen vangnet. In de configuratie geldt het voor iedereen, CI inbegrepen.slowals label, niet als skip. De trage test is een goede test; hij moet draaien, alleen niet bij elke opslag. Een label laat je kiezen; een skip zou hem uit elke run verwijderen.pytest.skip()in de test voor de API-sleutel, nietskipif. Beide zouden hier werken, maar de controle hoort bij de code die de sleutel nodig heeft. Wanneer een test halverwege pas leert dat ze niet verder kan, is de interne vorm de enige die werkt.raises=ValueErrorenstrict=Truesamen.raises=zorgt dat de test faalt vanwege bug #51 en niet vanwege een typfout;strict=Truezorgt dat de dag waarop de bug gerepareerd is wordt opgemerkt. Elk sluit een andere manier af waarop de marker kan gaan liegen.- Een geparametriseerde fixture, niet
parametrize, voor de formaten. Twee tests delen dezelfde drie invoeren. Met een fixture staat de lijst op één plek; een vierde format toevoegen voegt het meteen aan beide tests toe. - Ids
whole,decimals,comma. In de rapportage zegt[comma]waar het geval over gaat.[1,090]zou hier ook werken, maar ids blijven werken wanneer de waarden lang of niet weergeefbaar zijn.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken