Marker, skip und xfail — welche Tests laufen wo
Sage pytest, was jeder Test braucht und was du von ihm erwartest — mit skip, skipif, importorskip und xfail; was strict=True, raises= und XPASS bedeuten; registriere eigene Marker und wähle mit -m aus; plus parametrisierte und Factory-Fixtures.
Wie möchtest du lernen?
Das Problem, das wir lösen
Ein kleines Projekt, shop, mit einem Modul und zwei Testdateien. 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 prüft es mit einfachen Werten. test_export.py liest Preise aus YAML, wofür das Drittanbieter-Paket yaml nötig ist:
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.0Auf einer Maschine, auf der yaml nicht installiert ist, sagt 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.13sLies die letzten zwei Zeilen. Interrupted. Die beiden guten Tests in test_money.py liefen nie. Eine einzige Datei, die auf dieser Maschine nicht laufen konnte, nahm den ganzen Durchlauf mit in den Abgrund.
Das ist die Wirklichkeit in jedem Projekt, das lange genug lebt. Manche Tests brauchen eine Bibliothek, die optional ist. Manche haben nur auf Linux Sinn. Manche brauchen einen API-Schlüssel, den es nur in CI gibt. Manche beschreiben einen Fehler, den alle kennen und den bisher niemand behoben hat. Manche dauern eine Minute, und du willst nicht bei jedem Speichern eine Minute warten.
Diese Tests zu löschen ist falsch, und auskommentieren ebenso. Was du willst, ist, pytest zu sagen, was jeder Test braucht und was du von ihm erwartest, und pytest entscheiden zu lassen, was es bei jedem Durchlauf damit macht. Das Werkzeug dafür ist der Marker.
Am Ende dieses Kapitels kannst du
- Einen Test immer überspringen, unter einer Bedingung oder direkt im Test — und den Grund sichtbar machen
- Eine ganze Datei überspringen, wenn ein optionales Paket fehlt, mit
pytest.importorskip - Einen bekannten Fehler mit
xfailmarkieren und mitstrict=Trueundraises=präzise machen - Die Report-Buchstaben
s,xundXlesen und Details mit-rs,-rx,-rAanfordern - Eigene Marker erfinden, sie registrieren und Tippfehler mit
--strict-markersabfangen - Tests mit
-m "not slow"und-m "slow and db"auswählen - Eine Fixture schreiben, die jeden Test mehrmals laufen lässt, und eine Fixture, die eine Funktion zurückgibt
Voraussetzungen: Mocking.
Bevor du den Test schreibst
Marker drehen sich nicht um was ein Test prüft — das haben die früheren Kapitel abgedeckt. Sie drehen sich um zwei Fragen, die du beantwortest, bevor der Test geschrieben ist — und die die meisten Leute erst beantworten, nachdem ein Durchlauf aus den falschen Gründen rot geworden ist.
1. Was braucht dieser Test, um überhaupt zu laufen? Ein Paket, das keine harte Abhängigkeit ist, ein Betriebssystem, eine Python-Version, eine Umgebungsvariable, die ein Geheimnis enthält, eine Datenbank, ein Netzwerk, viel Zeit. Jedes davon ist eine Voraussetzung. Fehlt sie, hat der Test nichts zu sagen: Er ist nicht bestanden, und er ist auch nicht fehlgeschlagen.
2. Was erwarte ich heute von ihm? Normalerweise: dass er besteht. Aber wenn der Code einen bekannten Fehler hat und du den Test schreibst, der das korrekte Verhalten beschreibt, erwartest du, dass er fehlschlägt — und zwar auf eine ganz bestimmte Weise.
Diese Antworten entscheiden über den Marker, also schreibe sie zuerst auf. Für das Projekt shop sieht der Plan so aus:
| Fall | Eingabe / Situation | Braucht | Erwartet | Marker | |---|---|---|---|---| | simpler Preis | "1,250.50" | nichts | 1250.5 | keiner | | YAML-Export | "pen: '15.00'" | das Paket yaml | 15.0, wo installiert | pytest.importorskip("yaml") | | POSIX-Pfad | "data", "prices.csv" | nicht Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | echte Kurse | die echte API | RATES_API_KEY gesetzt | ein Kurs, nur wo der Schlüssel existiert | pytest.skip() im Test | | Währungssymbol | "$1,250" | nichts | 1250.0 — heute ValueError (Bug #42) | xfail(raises=ValueError, strict=True) | | viele Preise | 1000 Strings | eine Sekunde oder mehr | korrekte Summe | slow (eigen) |
Lies die Tabelle spaltenweise. Die Spalte Braucht erzeugt Skips. Die Spalte Erwartet erzeugt Xfails. Die Laufzeit erzeugt einen eigenen Marker, mit dem du auswählen kannst, was läuft.
Was du vorbereitet haben musst, bevor davon etwas funktioniert:
- eine virtuelle Umgebung mit installiertem
pytest, undmoney.py, das aus den Tests importierbar ist (aus dem Projektverzeichnis heraus ausführen, wie in früheren Kapiteln); - eine
pyproject.tomlim Projektstamm, denn dort werden eigene Marker registriert; - den irgendwo aufgeschriebenen Fehler mit einer Nummer, damit
reason=darauf verweisen kann.
Und was du nicht markierst:
- Überspringe keinen Test nur deshalb, weil er fehlschlägt und du nicht weißt, warum. Ein Skip versteckt den Fehler vor jedem späteren Durchlauf. Repariere ihn, oder verstehe ihn gut genug, um
xfail(raises=...)mit einem Grund zu schreiben. - Markiere keinen wackeligen Test als
xfail. Ein Test, der manchmal besteht, zeigt an manchen TagenXund an anderenx, und mitstrict=Trueschlägt er zufällig fehl. Flakiness ist ein Bug im Test; repariere den stattdessen. - Nicht alles als
slowlabeln. Wenn bei jedem Durchlauf die meisten Tests der Testsuite abgewählt werden, prüft der alltägliche Durchlauf nichts. Label nur die wenigen Tests, die wirklich Sekunden kosten. - Teste nicht, ob pytest korrekt überspringt. Du testest
money.py, nicht pytest. Der Marker ist Konfiguration; der Bericht des Durchlaufs ist Beweis genug, dass er funktioniert.
Der Rest des Kapitels setzt diese Tabelle Zeile für Zeile um.
skip — nicht dieser Test, und hier ist der Grund
Zuerst die Art, wie es üblicherweise gemacht wird, bevor jemand Marker kennt — ein frühes 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.07sBestanden. Der Test hat überhaupt nichts geprüft, und der Report sagt, er sei erfolgreich gewesen. Auf einem Laptop ohne Schlüssel ist das eine Lüge, die bei jedem einzelnen Durchlauf erzählt wird, und der grüne Punkt ist nicht von einem echten zu unterscheiden. Die Lösung ist nicht, die Bedingung zu umgehen, sondern sie ehrlich zu melden — als Skip.
Der einfachste Marker ist ein Dekorator, der sagt „Führe das nicht aus“, mit einem Grund dazu:
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 verschiedene Arten zu entscheiden:
@pytest.mark.skip(reason=...)— wird immer übersprungen. Der Körper läuft nie.@pytest.mark.skipif(condition, reason=...)— wird übersprungen, wenn die Bedingung wahr ist. Die Bedingung wird ausgewertet, wenn die Datei eingesammelt wird. Dieser Durchlauf lief auf Linux, also lieftest_posix_pathund bestand; die Python-Version war 3.12, also wurdetest_new_featureübersprungen.pytest.skip("...")im Test aufgerufen — die Entscheidung fällt, während der Test läuft. Benutze das, wenn du erst mittendrin erfährst, zum Beispiel nachdem du dir die Umgebung angesehen hast oder das, was eine Fixture zurückgegeben hat.
Jedes s in der Fortschrittszeile ist ein übersprungener Test. Ein Skip ist kein Fehlschlag: Der Durchlauf ist grün.
Aber die Gründe sind nirgends zu sehen. Standardmäßig fasst pytest nur Fehlschläge und Fehler zusammen. Fordere die Skips mit -rs an:
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.10sDeshalb zählt der reason. In drei Monaten sagt „skipped“ allein niemandem, ob der Test wieder eingeschaltet werden kann.
importorskip — wenn eine ganze Datei ein Paket braucht
Zurück zu test_export.py. Ersetze import yaml durch Folgendes:
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") versucht den Import. Funktioniert er, gibt er das Modul zurück — deshalb wird es yaml zugewiesen. Schlägt er fehl, wird die ganze Datei übersprungen, mit dem Importfehler als Grund. Die anderen zwei Tests laufen jetzt. Installiere yaml, und dieselbe Datei läuft vollständig, ohne Änderung.
xfail — ein Fehler, den du kennst
Ein Skip sagt „Dieser Test kann hier nicht laufen.“ Es gibt eine zweite, andere Aussage: „Dieser Test läuft und wird voraussichtlich fehlschlagen, weil der Code einen bekannten Fehler hat.“
parse_price hat einen. Er kann kein Währungssymbol verarbeiten:
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 als ein übersprungener Test läuft ein xfail-Test tatsächlich. Er ist fehlgeschlagen, wie vorhergesagt, also zeigt er ein kleingeschriebenes x, und der Durchlauf bleibt grün. Der Test ist jetzt ein schriftliches Protokoll des Fehlers, das in der Testsuite liegt, statt einer Notiz in jemandes Kopf.
Warum nicht einfach skip? Weil ein übersprungener Test nie läuft und dir daher nie etwas sagen kann — nicht, wenn der Fehler behoben wird, und nicht, wenn sich der Code so ändert, dass er auf eine neue Weise fehlschlägt. Ein xfail-Test läuft jedes Mal, und sein Ergebnis wird mit dem verglichen, was du vorhergesagt hast. Skip ist für „kann hier nicht laufen“; xfail ist für „läuft, und ich weiß, was er sagen wird.“
Jetzt repariert jemand Bug #42 und ändert money.py so, dass es das $ entfernt:
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.12sEin großes X, XPASS: „fehlschlagen erwartet, aber bestanden.“ Und der Durchlauf ist immer noch grün — der Exit-Code ist 0. Niemand sieht sich einen grünen Durchlauf an, also bleibt der Marker, und der Test behauptet weiterhin einen Fehler, den es nicht mehr gibt.
strict=True — gute Nachrichten laut machen
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.10sMit strict=True ist ein unerwartetes Bestehen ein Fehlschlag. Das klingt kontraproduktiv, und genau das willst du: Die Person, die den Fehler behoben hat, wird aufgefordert, den Marker zu entfernen, und der Test wird zu einem normalen Test, der die Reparatur von da an bewacht. Wenn du das für jeden xfail im Projekt willst, setze xfail_strict = true in der pytest-Konfiguration.
raises= — aus dem richtigen Grund fehlschlagen
Ein xfail ohne Argumente akzeptiert jeden Fehlschlag. Dazu gehören auch die, die du nicht meintest:
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 haben denselben Tippfehler, parse_prise. test_loose hat den NameError verschluckt und ein ruhiges x gemeldet — er wäre für immer „fehlschlagen erwartet“ geblieben, ohne jemals parse_price zu testen. test_precise hat angegeben, welchen Fehlschlag er erwartet — den ValueError, den float("$1250") auslöst —, sodass jede andere Exception als echter Fehlschlag gemeldet wird. Korrigiere den Tippfehler, und beide werden zu x.
Die Report-Buchstaben
Die Fortschrittszeile verwendet ein Zeichen pro Test, und -r nimmt dieselben Buchstaben, um auszuwählen, was in die Zusammenfassung kommt:
| Buchstabe | Bedeutung | In der Zusammenfassung mit | |---|---|---| | . | bestanden | -rp | | F | fehlgeschlagen | -rf (standardmäßig an) | | E | Fehler in Setup oder Teardown | -rE (standardmäßig an) | | s | übersprungen | -rs | | x | xfailed — wie erwartet fehlgeschlagen | -rx | | X | xpassed — unerwartet bestanden | -rX |
Buchstaben lassen sich kombinieren: -rsx zeigt Skips und Xfails zusammen. -rA zeigt alle, inklusive der bestandenen:
..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.08sDeine eigenen Marker
skip und xfail ändern, was pytest mit einem Test macht. Ein Marker kann auch ein einfaches Label sein, mit einem Namen, den du dir ausdenkst, das aber nichts ändert, bis du es zur Auswahl von Tests benutzt. Sage, manche Tests sind langsam, und manche brauchen eine Datenbank:
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 bestanden, und pytest beschwert sich über jede Verwendung. pytest.mark.<irgendetwas> wird akzeptiert — das Attribut wird sofort erstellt —, also kann pytest slow nicht von einem Tippfehler bei etwas anderem unterscheiden. Es fordert dich auf, die Namen zu erklären, die du meinst.
Marker registrieren
Die Liste kommt in die pyproject.toml, ein String pro Marker, der Name vor dem Doppelpunkt und danach eine Beschreibung:
[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.11sDie Warnungen sind weg, und pytest --markers listet sie jetzt für die nächste Person auf, die zum Projekt stößt:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseAuswählen mit -m
Jetzt zahlen sich die Labels aus. -m nimmt einen Ausdruck über Markernamen, mit and, or, not und Klammern:
$ 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.07sAbgewählt ist nicht übersprungen. Ein übersprungener Test wurde ausgewählt und hat dann abgelehnt zu laufen; ein abgewählter Test wurde nie ausgewählt. -m "not slow" ist der Durchlauf, den du bei jedem Speichern machst; der volle Durchlauf ist für CI und bevor du pushst.
--strict-markers — aus einem Tippfehler wird ein Fehler
Die Warnung ist leicht zu übersehen. Hier ein Test, den jemand später hinzugefügt hat, mit falsch geschriebenem Marker:
@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.07s-m "not slow" wurde angefordert, und der Durchlauf dauerte trotzdem eine Sekunde: Der falsch geschriebene Test ist nicht slow, also lief er. Mit --strict-markers stoppt ein unregistrierter Marker stattdessen das Sammeln:
==================================== 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.13sDu willst den Flag nicht jedes Mal tippen, also schreibe ihn 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 — einen Marker für die ganze Datei
Wenn jeder Test in einer Datei die Datenbank braucht, ist das Dekorieren jedes einzelnen reine Wiederholung. Eine Modulvariable namens pytestmark gilt für jeden Test in der Datei:
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.07sDer Name muss exakt pytestmark sein. Er kann auch eine Liste sein, pytestmark = [pytest.mark.db, pytest.mark.slow].
Marker auf einem einzelnen Fall von parametrize
Ein Marker auf einem parametrisierten Test gilt für jeden Fall. Um einen Fall zu markieren, wickele ihn in pytest.param(..., marks=...) ein:
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 ====================Der bekannte Fehler lebt neben den Fällen, die funktionieren, in derselben Tabelle, und am Tag, an dem er behoben ist, löst du einen pytest.param-Wrapper auf.
Weitergehen: Fixtures, die variieren, und Fixtures, die bauen
Zwei Fixture-Muster passten nicht in die Fixture-Kapitel, und Marker sind ein guter Anlass für sie, weil beide die Tests vervielfachen oder formen, die du gerade auswählen gelernt hast.
Eine parametrisierte Fixture
parametrize variiert die Eingaben eines Tests. Manchmal willst du, dass jeder Test, der eine Fixture verwendet, einmal pro Wert dieser Fixture läuft. Gib @pytest.fixture eine params-Liste und lies den aktuellen Wert aus 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 ===============================Zwei Tests, drei Werte, sechs Durchläufe. request ist eine eingebaute Fixture, die den Test beschreibt, der eingerichtet wird; request.param gibt es nur, wenn die Fixture params hat. ids benennt jeden Wert im Report — ohne sie baut pytest eine id aus dem Wert selbst, was bei langen oder nicht druckbaren Werten schwer zu lesen ist. Die Liste darf auch pytest.param(...)-Einträge mit marks= enthalten, genau wie in parametrize.
Wo sich das lohnt: dieselbe Gruppe von Tests gegen mehrere Backends, mehrere Dateiformate, mehrere Währungen. Füge einen Wert zu params hinzu, und jeder Test, der die Fixture verwendet, deckt ihn ab.
Eine Factory-Fixture
Eine Fixture kann keine Argumente vom Test annehmen; der Test benennt sie nur. Was tust du also, wenn ein Test zwei Objekte braucht, die unterschiedlich gebaut sind? Gib eine Funktion zurück:
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.08sDie Fixture läuft weiterhin einmal pro Test und bekommt weiterhin ihre eigenen Fixtures (tmp_path hier, was sich auch ums Löschen der Dateien kümmert). Was sie übergibt, ist _make, und der Test ruft es so oft auf, wie er will, mit welchen Argumenten er will. Der Name make_... ist die übliche Konvention und verrät dem Leser auf einen Blick, dass diese Fixture eine aufzurufende Funktion ist, kein fertiger Wert.
Ein vollständiges Beispiel
Alles zusammen in einem Projekt. 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, mit demselben money.py — immer noch mit 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")Der alltägliche Durchlauf, mit Gründen:
$ 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.09sNur der langsame, und der Offline-Durchlauf, der sowohl die langsamen als auch die Netzwerk-Tests auslässt:
$ 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.08sZähle nach: Eine Fixture mit drei Werten ergibt drei Durchläufe von test_share_is_positive, drei Fälle von test_share, einen langsamen Test und einen Netzwerk-Test — acht insgesamt, und genau das ergibt 1 passed, 7 deselected.
Und jedes Ergebnis sagt die Wahrheit. Der Netzwerk-Test tut nicht so, als bestünde er ohne Schlüssel; er sagt, dass er übersprungen wurde, und warum. Bug #42 ist in der Testsuite, und weil er strict mit raises=ValueError ist, wird der Durchlauf am Tag der Reparatur rot und fordert, den Marker zu entfernen.
Wenn etwas schiefgeht
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Der Marker ist nicht registriert. Füge ihn zu markers in der pyproject.toml hinzu. Wenn der Name wirklich ein Tippfehler ist, repariere ihn — und füge --strict-markers zu addopts hinzu, sodass der nächste ein Fehler statt einer Warnung ist.
`'slwo' not found in markers configuration option` Das ist --strict-markers bei der Arbeit: Die Datei verwendet einen Marker, der nicht in der Liste steht. Repariere die Schreibweise oder registriere den neuen Namen.
8 deselected und no tests ran, Exit-Code 5 -m hat nichts gefunden. Ein falsch geschriebener Name in einem -m-Ausdruck ist kein Fehler, selbst mit --strict-markers nicht — -m sloww wählt einfach keinen Test aus. Prüfe den Namen gegen 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 Der -m-Ausdruck ist unvollständig. Zitiere den ganzen Ausdruck und stelle sicher, dass jedes and/or auf beiden Seiten etwas hat.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") ohne reason=. Eine Boolesche Bedingung sagt nichts darüber, warum, also besteht pytest auf einem Grund.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") wurde auf der obersten Ebene einer Datei aufgerufen. Für ein fehlendes Paket nutze pytest.importorskip; sonst schreibe pytest.skip("...", allow_module_level=True) oder verwende ein skipif in pytestmark.
[XPASS(strict)] bug #42 Gute Nachricht, als Fehlschlag gemeldet: Der Fehler ist behoben. Entferne den xfail-Marker und behalte den Test.
Ein xfail-Test, der nach der Reparatur des Fehlers immer noch x bleibt Er scheitert aus einem anderen Grund — oft ein Tippfehler im Test selbst. Füge raises= mit der Exception hinzu, die du wirklich erwartest, und alles andere zeigt sich als echter Fehlschlag.
AttributeError: 'SubRequest' object has no attribute 'param' Die Fixture liest request.param, hat aber keine params=-Liste und wurde auch sonst nicht parametrisiert. Füge params=[...] zu @pytest.fixture hinzu.
fixture 'name' not found, das auf eine Fixture zeigt Eine Fixture wurde als def make_user(name): geschrieben, in der Hoffnung, der Test würde name übergeben. Fixture-Argumente sind andere Fixtures, also hat pytest nach einer Fixture namens name gesucht. Mach daraus eine Factory: eine innere Funktion, die name annimmt, zurückgegeben von der Fixture.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Wie lauten die Fortschrittszeile und die letzte Zeile von pytest -q? (Denke daran, dass round(2.675, 2) 2.67 ergibt.)
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 ist in der pyproject.toml registriert, und --strict-markers ist aus. Du führst pytest -m "not slow" aus, und der Durchlauf dauert trotzdem fünf Sekunden. Warum?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"` führt nur die langsamen Tests aus
- BDer Marker ist falsch geschrieben als `slwo`, der Test ist also nicht `slow` und wird ausgewählt; pytest warnt nur
- C`time.sleep` sorgt dafür, dass pytest Marker ignoriert
- D`-m` funktioniert nur mit eingebauten Markern wie `skip`
Fehler #12 wurde tatsächlich bereits behoben, sodass der assert wahr ist. Was meldet 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 mit `[XPASS(strict)]`, exit code 1
- C`s` — der Test wird übersprungen, weil er markiert ist
- D`x` — 1 xfailed, exit code 0
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Nimm eine Testsuite, die du schon hast — aus einem früheren Kapitel oder aus deiner eigenen Arbeit — und gib ihr ein ehrliches Set an Markern:
- Registriere zwei eigene Marker in der
pyproject.toml(zum Beispielslowundnetwork) und schalte--strict-markersinaddoptsein. - Markiere mindestens einen Test mit jedem. Benutze
time.sleep(1), um einen langsamen zu faken, falls du keinen hast. - Füge einen Test hinzu, der eine Umgebungsvariable braucht, und überspringe ihn von innen mit
pytest.skip, wenn die Variable fehlt. - Finde oder erfinde einen Fehler in deinem Code. Schreibe den Test, der bestehen sollte, und markiere ihn als
xfailmitstrict=Trueund einem präzisenraises=. - Füge eine Fixture mit
paramsundidshinzu, die von mindestens zwei Tests verwendet wird.
Dann führe aus und speichere die Ausgabe von jedem:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
Zum Schluss zwei Experimente. Schreibe einen deiner Marker falsch und führe einmal ohne und einmal mit --strict-markers aus. Repariere dann den Fehler aus Schritt 4 und führe erneut aus. Lies, was pytest jeweils sagt — diese beiden Meldungen sind es, die eine Suite mit Markern ehrlich halten, und du erkennst sie beim zweiten Mal leichter, wenn du sie selbst einmal verursacht hast.
Lösung
Eine Lösung, die money.py des Projekts shop verwendet. Der Fehler für Schritt 4 ist ein neuer, Bug #51: Eine leere Zelle in einer Tabelle kommt als "" an und sollte als 0.0 zählen — aber float("") löst einen ValueError aus.
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.0Die drei Durchläufe:
$ 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.08sDas erste Experiment: @pytest.mark.slow falsch geschrieben als @pytest.mark.slwo. Ohne --strict-markers (hier mit -o addopts="" abgeschaltet) dauert der Durchlauf trotzdem eine Sekunde, weil der „langsame“ Test nicht abgewählt wurde:
$ 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.09sDamit weigert sich der Durchlauf, zu 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.13sDas zweite Experiment: Bug #51 in money.py behoben:
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.08sEntferne die xfail-Zeile, und der Test besteht von da an als normaler Test.
Warum jede Entscheidung:
--strict-markersinaddopts, nicht auf der Kommandozeile. Ein Sicherheitsnetz, an das du denken musst einzuschalten, ist kein Sicherheitsnetz. In der Konfiguration gilt es für alle, CI eingeschlossen.slowals Label, nicht als Skip. Der langsame Test ist ein guter Test; er sollte laufen, nur nicht bei jedem Speichern. Ein Label lässt dich wählen; ein Skip würde ihn aus jedem Durchlauf nehmen.pytest.skip()im Test für den API-Schlüssel, nichtskipif. Beides würde hier funktionieren, aber die Prüfung gehört zu dem Code, der den Schlüssel braucht. Wenn ein Test erst mittendrin erfährt, dass er nicht weiter kann, ist die Form von innen die einzige, die funktioniert.raises=ValueErrorundstrict=Truezusammen.raises=sorgt dafür, dass der Test wegen Bug #51 fehlschlägt und nicht wegen eines Tippfehlers;strict=Truesorgt dafür, dass der Tag der Reparatur auffällt. Jedes schließt eine andere Lücke, durch die der Marker anfangen kann zu lügen.- Eine parametrisierte Fixture statt
parametrizefür die Formate. Zwei Tests teilen sich dieselben drei Eingaben. Mit einer Fixture lebt die Liste an einer Stelle; ein viertes Format hinzuzufügen, fügt es beiden Tests auf einmal hinzu. - Ids
whole,decimals,comma. Im Report sagt[comma], worum es bei dem Fall geht.[1,090]würde hier auch funktionieren, aber ids funktionieren weiter, wenn die Werte lang oder nicht druckbar sind.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen