Async Tests, Hooks und Plugins schreiben
Async Code testen mit pytest-asyncio, dann pytest mit Hooks in conftest.py erweitern — ein --runslow Flag, ein Marker, ein Report-Header — und schließlich daraus ein eigenes Plugin machen und es mit pytester testen.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist ein kleines Stück asynchronen Codes — der Art, die mit einem Netzwerk spricht und nicht untätig warten will, bis die Antwort kommt.
prices.py:
import asyncio
PRICES = {"pen": 15.0, "notebook": 60.0}
async def fetch_price(item):
await asyncio.sleep(0.01) # stands in for a network call
if item not in PRICES:
raise KeyError(item)
return PRICES[item]Du testest es so, wie du bisher alles getestet hast: eine Funktion, deren Name mit test_ beginnt. Es ist eine async Funktion, weil await nur darin erlaubt ist. Und um sicherzugehen, dass der Test wirklich etwas prüft, ist der erwartete Preis absichtlich falsch:
test_prices.py:
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0pytest -q:
F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - Failed: async def functions are not n...
1 failed in 0.11sLies die Fehlermeldung genau. Sie sagt nichts über 15.0 oder 999.0. Die Assertion lief nie. Das Aufrufen einer async def Funktion führt ihren Körper nicht aus — sie erzeugt nur eine Coroutine, und etwas muss eine Ereignisschleife antreiben, um sie auszuführen. pytest ohne Plugin tut das nicht. (Ältere pytest-Versionen übersprangen einen solchen Test mit einer Warnung, sodass er grün aussah, obwohl er nichts testete. pytest 9 lässt ihn fehlschlagen, was viel besser ist.)
Das ist die erste Hälfte dieses Kapitels: pytest dazu zu bringen, asynchronen Code auszuführen. Die zweite Hälfte ist die tiefere Idee hinter der Lösung — pytest selbst ist eine Sammlung von Hooks, und ein Plugin ist einfach Code, der einige davon implementiert. Sobald du das siehst, kannst du deine eigenen schreiben: ein --runslow Flag, einen Marker, eine Zeile im Berichts-Header und eine Test-Suite für das Plugin selbst.
Am Ende dieses Kapitels kannst du
- Erklären, warum ein bloßer
async def test_...nicht läuft, und den Fehlschlag erkennen - Async Tests mit
pytest-asyncioausführen, mittels@pytest.mark.asynciooderasyncio_mode = "auto" - Async Fixtures schreiben, einschließlich solcher mit Teardown nach
yield - Eine Kommandozeilen-Option mit
pytest_addoptionhinzufügen und mitrequest.config.getoptionlesen - Langsame Tests überspringen, solange
--runslownicht gegeben ist, mittelspytest_collection_modifyitems - Einen Marker in
pytest_configureregistrieren und eine Zeile mitpytest_report_headerhinzufügen - Sagen, wo pytest Hooks findet:
conftest.py,-pundpytest11Entry Points - Ein Plugin mit der
pytesterFixture testen
Voraussetzungen: Coverage und CI.
Bevor du den Test schreibst
Dieses Kapitel testet zwei unterschiedliche Arten von Dingen — eine async Funktion und ein pytest Plugin — und beide erfordern etwas Überlegung vor der ersten Testzeile.
Was versprochen wird. Schreib den Vertrag zuerst in einfache Worte, denn die Tests sind dieser Vertrag, übersetzt.
fetch_price(item)ist eine Coroutine-Funktion. Mit einem bekannten Item erwartet, gibt sie den Preis dieses Items zurück. Mit einem unbekannten Item erwartet, wirft sieKeyError. Viele gleichzeitige Aufrufe sollten sich überlappen, nicht queuen.- Das Slow-Test-Plugin verspricht: ohne
--runslowwird jeder Test, der mitslowmarkiert ist, übersprungen, mit einer Begründung; mit--runslowläuft alles; der Header sagt, welche; und derslowMarker ist registriert, sodass er keine Warnung wirft.
Was bereit sein muss. Async Tests brauchen einen Runner, den pytest nicht hat: installiere pytest-asyncio und entscheide dich für strict oder auto Modus bevor du Tests schreibst, weil die Decoratoren, die du schreibst, davon abhängen. Der zu testende Code muss importierbar sein — prices.py im Projekt-Root, und später das Plugin-Modul auch (pythonpath = ["."]). Das Testen des Plugins braucht die pytester Fixture, aktiviert. Und nichts sollte ein echtes Netzwerk brauchen: asyncio.sleep steht dafür ein.
Der Plan. Eine Zeile pro Verhalten, jeweils mit Eingabe und was du erwartest zu sehen:
| Fall | Eingabe | Erwartet | | --- | --- | --- | | bekanntes Item | await fetch_price("pen") | 15.0 | | unbekanntes Item | await fetch_price("stapler") | KeyError | | await vergessen | fetch_price("pen") in einem normalen Test | ein Coroutine-Objekt, kein Preis | | viele auf einmal | 400 Aufrufe durch asyncio.gather | alle 400 zurück, in etwa der Zeit eines einzigen | | Standard-Lauf | pytest mit einem slow Test | 1 passed, 1 skipped | | Flag gegeben | pytest --runslow | 2 passed | | Header | pytest | eine Zeile slow tests: skipped (use --runslow) | | Marker | pytest --markers | slow gelistet, kein PytestUnknownMarkWarning |
Was nicht zu testen ist. Nicht asyncio.sleep, nicht die Event Loop — Python testet die. Nicht pytest's eigenen Skip-Mechanismus: du testest, dass dein Hook den Skip anhängt, indem du Outcomes zählst, nicht wie pytest ein s druckt. Auch keine exakten Timings: „400 Aufrufe sind in unter einer Sekunde fertig“ ist eine stabile Aussage auf jeder Maschine, während „in 0,013 Sekunden“ es nicht ist.
Der Rest des Kapitels arbeitet diese Tabelle Zeile für Zeile ab.
Async Tests mit pytest-asyncio ausführen
Die Fehlermeldung listet mehrere Plugins auf. Für Code, der auf asyncio baut, ist die übliche Wahl pytest-asyncio:
pip install pytest-asyncioInstallieren allein reicht nicht. Führe denselben Test noch einmal aus, und die Ausgabe ist Wort für Wort dieselbe: async def functions are not natively supported. Standardmäßig arbeitet pytest-asyncio im strict Modus — es übernimmt nur Tests, die du ihm explizit übergeben hast. Du übergibst einen mit einem Marker:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 999.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
@pytest.mark.asyncio
async def test_pen_price():
> assert await fetch_price("pen") == 999.0
E assert 15.0 == 999.0
test_prices.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - assert 15.0 == 999.0
1 failed in 0.10sJetzt schlägt es aus dem richtigen Grund fehl. assert 15.0 == 999.0 — die Coroutine lief, await lieferte eine echte Zahl, und die Assertion verglich sie. Dass ein Test mit dem falschen Wert fehlschlägt, beweist, dass er wirklich läuft. Korrigiere die Zahl, und füge einen Test für den Fehlerpfad hinzu, während du dabei bist:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 15.0
@pytest.mark.asyncio
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler").. [100%]
2 passed in 0.10spytest.raises funktioniert in einem async Test genau so wie anderswo; das await geht einfach in den with Block.
Es gibt noch einen häufigen Fehler, den man kennen sollte: das Problem zu umgehen, indem man einen normalen def Test schreibt und die async Funktion ohne await aufruft.
from prices import fetch_price
def test_pen_price():
assert fetch_price("pen") == 15.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
def test_pen_price():
> assert fetch_price("pen") == 15.0
E AssertionError: assert <coroutine object fetch_price at 0x70659d650e80> == 15.0
E + where <coroutine object fetch_price at 0x70659d650e80> = fetch_price('pen')
test_noawait.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_noawait.py::test_pen_price - AssertionError: assert <coroutine ob...
1 failed in 0.08s
sys:1: RuntimeWarning: coroutine 'fetch_price' was never awaitedDer Report sagt genau, was passiert ist: fetch_price('pen') gab ein Coroutine-Objekt zurück, und eine Coroutine ist nicht gleich 15.0. Python fügt hinzu, dass die Coroutine verworfen wurde, ohne je zu laufen. Dieser hier schlägt wenigstens mit Fehlermeldung fehl. Die gefährliche Variante ist assert fetch_price("pen") ohne Vergleich — ein Coroutine-Objekt ist truthy, sodass dieser Test 1 passed, 1 warning meldet, während er nichts prüft. Jeder Test von async Code muss selbst async def sein und muss await schreiben.
asyncio_mode = "auto" und async Fixtures
@pytest.mark.asyncio über jeden Test zu schreiben, wird schnell lästig. In einem Projekt, wo async der Normalfall ist, schalte den Modus in pyproject.toml um:
[tool.pytest.ini_options]
asyncio_mode = "auto"Im auto Modus nimmt pytest-asyncio jeden async def Test und jede async def Fixture, ohne Marker. Der Header eines vollen pytest Laufs sagt dir, welcher Modus gilt:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionFixtures können auch async sein. Hier wird ein Warenkorb aufgebaut, dem Test übergeben, und danach geschlossen — mit einem await auf beiden Seiten des yield:
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
self.closed = False
async def add(self, item):
self.items.append((item, await fetch_price(item)))
async def close(self):
self.closed = True
@pytest.fixture
async def cart():
c = Cart()
yield c
await c.close()
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]
async def test_two_items(cart):
await cart.add("pen")
await cart.add("notebook")
assert [price for _, price in cart.items] == [15.0, 60.0].. [100%]
2 passed in 0.11sDas yield funktioniert so wie im Fixtures-Kapitel: alles davor ist Setup, alles danach ist Teardown, und Teardown läuft auch, wenn der Test fehlschlägt.
Im strict Modus braucht dieselbe Fixture ihren eigenen Decorator, @pytest_asyncio.fixture, aus import pytest_asyncio. Mit einem einfachen @pytest.fixture dort weiß pytest nicht, wie er sie ausführen soll, und stoppt beim Setup:
E [100%]
==================================== ERRORS ====================================
__________________________ ERROR at setup of test_add __________________________
'test_add' requested an async fixture 'cart', with no plugin or hook that handled it. This is an error, as pytest does not natively support it.
See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture
=========================== short test summary info ============================
ERROR test_cart.py::test_add - Failed: 'test_add' requested an async fixture ...
1 error in 0.09sDie Wahl ist also einfach. Strict Modus: markiere async Tests mit @pytest.mark.asyncio und async Fixtures mit @pytest_asyncio.fixture. Auto Modus: async def überall. Wähle eins pro Projekt und schreib es in die Konfiguration, damit niemand raten muss.
Wie hat pytest-asyncio das gemacht? Es hat pytest nicht geändert. Es hat ein paar von pytest Hooks implementiert — Funktionen mit festen Namen, die pytest an festen Momenten aufruft, etwa „ein Test steht kurz davor, zu laufen“. Das ist alles, was ein Plugin ist. Der Rest dieses Kapitels schreibt ein Plugin deiner eigenen.Hooks in conftest.py
Ein Hook ist eine Funktion namens pytest_<etwas>, die pytest an einem bestimmten Moment eines Laufs aufruft. Du registrierst sie nicht; du definierst sie an einem Ort, wo pytest hinschaut, und der Name erledigt den Rest. Der erste solche Ort ist die conftest.py Datei, die du schon für geteilte Fixtures nutzt.
Eine Kommandozeilen-Option: pytest_addoption
Angenommen, dieselben Tests sollen gegen deinen Laptop und gegen einen Staging-Server laufen. Die Adresse gehört auf die Kommandozeile, nicht in den Code:
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--shop-url", default="http://localhost:8000", help="where the shop under test runs"
)
@pytest.fixture
def shop_url(request):
return request.config.getoption("--shop-url")test_shop.py:
def test_shop_url(shop_url):
print("testing against", shop_url)
assert shop_url.startswith("http")pytest -q -s, dann pytest -q -s --shop-url https://staging.example.com:
testing against http://localhost:8000
.
1 passed in 0.07s
testing against https://staging.example.com
.
1 passed in 0.07spytest_addoption läuft einmal, bevor die Kommandozeile geparst wird, und parser.addoption nimmt dieselben Argumente wie Pythons argparse. Dann ist request.config das Konfigurationsobjekt des Laufs, und getoption liest den Wert zurück. Die Option erscheint auch in pytest --help:
--shop-url=SHOP_URL where the shop under test runsDie Option über eine Fixture bereitzustellen, ist das übliche Muster: Tests fragen nach shop_url und greifen nie direkt auf die Konfiguration zu.
Langsame Tests überspringen: pytest_collection_modifyitems
Ein häufiges Bedürfnis: manche Tests dauern Sekunden, und du willst sie nicht bei jedem Speichern — nur vor einem Push, oder in CI. Markiere sie slow, und überspringe sie, solange kein --runslow Flag gegeben ist.
Der verlockende erste Versuch ist, die Prüfung in jeden langsamen Test zu stellen: die Option oben lesen, pytest.skip() aufrufen, wenn sie fehlt. Es funktioniert, und es ist der falsche Weg. Die Regel steht dann in jedem langsamen Test statt an einem Ort; sobald jemand einen neuen langsamen Test schreibt und diese zwei Zeilen vergisst, läuft er bei jedem Speichern, und niemand merkt, warum die Suite langsamer wurde. Und der Test-Rumpf muss erst anfangen zu laufen, bevor er entscheiden kann, nicht zu laufen, also sind seine Fixtures schon eingerichtet — vielleicht eine Datenbank, vielleicht ein Server.
Der richtige Ort ist vor jedem Teststart. Nachdem pytest jeden Test gesammelt hat, und bevor irgendeiner läuft, ruft es pytest_collection_modifyitems(config, items) auf. items ist die Liste der gesammelten Tests, und der Hook darf sie ändern — eine Regel, an einem Ort, angewendet auf jeden Test, der den Marker trägt.
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="also run tests marked slow"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a test that takes seconds, not milliseconds")
def pytest_report_header(config):
if config.getoption("--runslow"):
return "slow tests: included"
return "slow tests: skipped (use --runslow)"
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)test_reports.py:
import time
import pytest
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
time.sleep(1) # pretend this crunches a year of sales
assert Truepytest -q, dann pytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sOhne das Flag ist der langsame Test ein s und der Lauf dauert ein Zehntel Sekunde. Mit ihm laufen beide und der zweite kostet seine volle Sekunde. action="store_true" macht --runslow zu einem Schalter: fehlt er, bedeutet False, ist er da, bedeutet True. In einem Hook gibt es kein request, also liest der Hook den Wert direkt aus config.
item.keywords enthält die Namen der Marker des Tests (unter anderem), also fragt "slow" in item.keywords „trägt dieser Test @pytest.mark.slow?“. item.add_marker(skip_slow) hängt einen Skip an, genau so, als hättest du @pytest.mark.skip(reason="needs --runslow") über den Test selbst geschrieben.
Den Marker registrieren und eine Header-Zeile hinzufügen
Zwei kleinere Hooks in dieser Datei verdienen ein Wort.
pytest_configure(config) läuft einmal, nachdem die Kommandozeile geparst wurde. config.addinivalue_line("markers", ...) registriert den slow Marker — dasselbe wie eine markers = [...] Zeile in pyproject.toml, aber vom Plugin selbst definiert. Ohne ihn erzeugt jedes @pytest.mark.slow einen PytestUnknownMarkWarning, und unter --strict-markers wird es ein Fehler. Jetzt listet pytest --markers ihn zuerst:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) gibt einen String (oder eine Liste von Strings) zurück, den pytest ganz oben im Lauf druckt. Es ist der richtige Ort, um alles zu sagen, was ein Leser eines CI-Logs wissen muss. pytest -rs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/shop
collected 2 items
test_reports.py .s [100%]
=========================== short test summary info ============================
SKIPPED [1] test_reports.py:10: needs --runslow
========================= 1 passed, 1 skipped in 0.07s =========================Ein übersprungener Test mit klarer Begründung ist ehrlich: die Zusammenfassung sagt, was nicht lief, und warum.
Wie pytest Hooks findet
Jeder dieser Hooks befindet sich in einem Modul. pytest sammelt solche Module — Plugins — aus mehreren Orten:
- Seine eigenen eingebauten Plugins. Der Großteil von pytest ist als Plugins geschrieben:
-k, Marker,tmp_path, der Terminal-Report. - Installierte Pakete, die einen
pytest11Entry Point deklarieren. So laden sichpytest-asyncio,pytest-covund der Rest selbst, sobald sie installiert sind. Dieplugins:Zeile im Header listet sie. - **Module, die mit
-pauf der Kommandozeile benannt sind, oder inaddopts. conftest.pyDateien, am Root deiner Tests und in jedem Unterverzeichnis (ein Unterverzeichnissesconftest.pygilt nur für Tests darunter). Einige frühe Hooks, wiepytest_addoption, werden nur in einerconftest.pyam Root beachtet.
Also kann die obige conftest.py unverändert ausgelagert werden. Benenne sie in pytest_slowtests.py um, und sie ist ein Plugin. Lade sie mit -p und dem Modulnamen:
pytest -p pytest_slowtestsDas Modul muss importierbar sein. Wenn es im Projekt-Root liegt, konfiguriere pythonpath, und um -p nicht jedes Mal tippen zu müssen, füge es in addopts ein:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"Um es über Projekte zu teilen, mache ein Paket daraus und deklariere den Entry Point. Die Gruppe muss pytest11 heißen; der Name links ist der Name des Plugins, und der Wert rechts ist das Modul:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "pytest-slowtests"
version = "0.1.0"
dependencies = ["pytest>=8"]
[project.entry-points.pytest11]
slowtests = "pytest_slowtests"Installiere es (pip install ./pytest-slowtests) in eine frische Umgebung, und in einem Projekt ohne jegliche conftest.py:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/other-project
plugins: slowtests-0.1.0
collected 2 items
test_reports.py .s [100%]
========================= 1 passed, 1 skipped in 0.01s =========================plugins: slowtests-0.1.0 — pytest fand es durch den Entry Point. Das Gegenteil funktioniert auch: -p no:slowtests schaltet ein installiertes Plugin für einen Lauf aus, was nützlich ist, wenn du ein Plugin verdächtigst, Probleme zu verursachen.
Ein Plugin testen mit pytester
Ein Plugin ändert, wie pytest sich verhält, also müssen seine Tests pytest ausführen und prüfen, was passiert. pytest liefert eine Fixture genau dafür, pytester. Sie ist standardmäßig deaktiviert; aktiviere sie im Testmodul (oder in der Root conftest.py):
pytest_plugins = ["pytester"]pytester gibt jedem Test ein leeres temporäres Verzeichnis, ein frisches kleines Projekt. pytester.makepyfile(...) schreibt eine Testdatei hinein, pytester.runpytest(...) führt pytest dort mit den Argumenten aus, die du gibst, und das Ergebnis hat assert_outcomes(passed=..., skipped=..., failed=...) zum Prüfen der Zahlen, und result.stdout.fnmatch_lines([...]) zum Prüfen von Ausgabezeilen (* darf als Wildcard genutzt werden). Die vollständige Version steht im kompletten Beispiel unten.
Ein vollständiges Beispiel
Ein kleines Projekt, das alles aus diesem Kapitel enthält: asynchroner Code, async Tests im auto-Modus, das Slow-Test-Plugin und Tests für das Plugin.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py ist die vom Kapitelanfang, und pytest_slowtests.py ist die conftest.py aus dem --runslow Abschnitt, umbenannt. pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_slowtests"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"tests/test_prices.py:
import asyncio
import pytest
from prices import PRICES, fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 15.0
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler")
@pytest.mark.slow
async def test_every_price_at_once():
names = list(PRICES) * 200
prices = await asyncio.gather(*(fetch_price(n) for n in names))
assert len(prices) == 400tests/test_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_quick():
assert True
@pytest.mark.slow
def test_slow():
assert True
"""
def run(pytester, *args):
# The inner run is a brand-new project in a temporary directory.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE)
return pytester.runpytest("-p", "pytest_slowtests", *args)
def test_slow_is_skipped_by_default(pytester):
result = run(pytester)
result.assert_outcomes(passed=1, skipped=1)
def test_runslow_runs_everything(pytester):
result = run(pytester, "--runslow")
result.assert_outcomes(passed=2)
def test_header_mentions_the_flag(pytester):
result = run(pytester)
result.stdout.fnmatch_lines(["slow tests: skipped (use --runslow)"])pytest -v (Header gekürzt):
slow tests: skipped (use --runslow)
configfile: pyproject.toml
testpaths: tests
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
collecting ... collected 6 items
tests/test_plugin.py::test_slow_is_skipped_by_default PASSED [ 16%]
tests/test_plugin.py::test_runslow_runs_everything PASSED [ 33%]
tests/test_plugin.py::test_header_mentions_the_flag PASSED [ 50%]
tests/test_prices.py::test_pen_price PASSED [ 66%]
tests/test_prices.py::test_unknown_item PASSED [ 83%]
tests/test_prices.py::test_every_price_at_once SKIPPED (needs --runslow) [100%]
========================= 5 passed, 1 skipped in 0.17s =========================Und pytest -q --runslow:
...... [100%]
6 passed in 0.40sDrei Dinge fallen auf.
Erstens, das Plugin funktioniert auch mit async Tests. test_every_price_at_once ist ein async def mit einem slow Marker, und der Skip wird darauf angewendet wie auf jeden anderen Test. Hooks sehen gesammelte Items; ob die Funktion hinter einem async ist, ist Aufgabe von pytest-asyncio, nicht deren.
Zweitens, asyncio.gather lief 400 Aufrufe von fetch_price, die jeweils 0,01 Sekunden schlafen, und der ganze Lauf dauerte weit unter einer Sekunde — nicht vier. Sie liefen gleichzeitig. Das ist der Grund, warum async Code existiert, und der Test beweist, dass er sich so verhält.
Drittens, die makeini Zeile in run. Der innere Lauf innerhalb von pytester ist ein separates Projekt mit seiner eigenen Konfiguration, und es lädt jedes installierte Plugin — einschließlich pytest-asyncio, das warnt, wenn asyncio_default_fixture_loop_scope nicht gesetzt ist. Dem inneren Projekt diese eine Einstellung zu geben, hält den äußeren Bericht sauber. Die allgemeine Lektion: ein pytester Lauf sieht deine installierten Plugins, aber nicht dein pyproject.toml.
Wenn das Plugin fehlerhaft ist, zeigen diese Tests das präzise. Hier war der Skip-Check vertippt als "slw", sodass nichts übersprungen wurde:
> result.assert_outcomes(passed=1, skipped=1)
E AssertionError: assert {'passed': 2,...rors': 0, ...} == {'passed': 1,...rors': 0, ...}
E
E Omitting 4 identical items, use -vv to show
E Differing items:
E {'passed': 2} != {'passed': 1}
E {'skipped': 0} != {'skipped': 1}
E Use -v to get more diffDarunter, unter Captured stdout call, druckt pytest die komplette Ausgabe des inneren Laufs, sodass du genau sehen kannst, was er tat.
Wenn es kracht
async def functions are not natively supported. Der Test ist ein async def, und nichts führt ihn aus. Entweder ist pytest-asyncio nicht installiert, oder es ist im strict Modus installiert und dem Test fehlt @pytest.mark.asyncio. Füge den Marker hinzu, oder setze asyncio_mode = "auto".
requested an async fixture 'cart', with no plugin or hook that handled it Eine async def Fixture ist mit einem einfachen @pytest.fixture im strict Modus dekoriert. Nutze @pytest_asyncio.fixture, oder schalte auf auto Modus um.
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, gefolgt von RuntimeWarning: coroutine 'fetch_price' was never awaited Ein normaler def Test rief eine async Funktion ohne await auf und verglich das Coroutine-Objekt selbst. Mach den Test zu async def und schreib await fetch_price("pen").
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Der Marker wird genutzt, aber nie registriert. Registriere ihn in pytest_configure mit config.addinivalue_line("markers", ...), oder in der markers Einstellung.
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> Eine Funktion beginnt mit pytest_ aber ist kein Hook, den pytest kennt — hier ein überflüssiges s. pytest prüft jeden pytest_ Namen in einem Plugin, was genau den Tippfehler fängt. Prüfe die Schreibweise gegen die Hook-Referenz.
ValueError: no option named '--runslow' config.getoption fragte nach einer Option, die kein pytest_addoption hinzugefügt hat. Entweder ist der Name anders, oder der pytest_addoption Hook befindet sich in einer conftest.py, die pytest nicht früh genug lädt — platziere sie im Root deiner Tests.
pytest: error: unrecognized arguments: --run-slow Die Option auf der Kommandozeile stimmt nicht mit der registrierten überein. pytest --help listet jede Option, die deine Plugins hinzugefügt haben, unter "custom options".
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p nimmt einen Modulnamen, und der konnte nicht importiert werden. Füge pythonpath = ["."] zur Konfiguration hinzu, oder installiere das Plugin als Paket.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
fetch_price("pen") gibt wirklich 15.0 zurück. Was meldet pytest -q?
# pytest-asyncio is installed, in its default (strict) mode
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0- A1 skipped, 1 warning
- B1 failed — async def functions are not natively supported.
- C1 failed — assert 15.0 == 999.0
- D1 passed
In strict Modus schlägt dieser Test beim Setup mit ERROR fehl. Was ist die Korrektur?
# pyproject.toml has no asyncio_mode, so pytest-asyncio is in strict mode
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
async def add(self, item):
self.items.append((item, await fetch_price(item)))
@pytest.fixture
async def cart():
yield Cart()
@pytest.mark.asyncio
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]- AErsetze `yield` durch `return`
- BDekoriere die Fixture mit `@pytest_asyncio.fixture` (oder setze `asyncio_mode = "auto"`)
- CEntferne `@pytest.mark.asyncio` vom Test
- DMache `test_add` zu einem normalen `def` Test
Beide Dateien liegen in einem Ordner. Was zeigt pytest -q ohne Flags?
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", default=False)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a slow test")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# test_reports.py
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
assert True- As 1 skipped
- B.F 1 passed, 1 failed
- C.. 2 passed
- D.s 1 passed, 1 skipped
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
Schreib ein Plugin, pytest_budget.py, das jedem Test ein Zeitbudget gibt.
- Füge eine Option
--budgetmit Default0.5(Sekunden) hinzu; nutzetype=floatinparser.addoption. - Stelle eine Fixture
budgetbereit, die den Wert viarequest.config.getoption("--budget")zurückgibt. - Registriere einen Marker
networkinpytest_configure, und druckebudget: 0.5s(oder was gegeben wurde) mitpytest_report_header. - In
pytest_collection_modifyitems, überspringe jeden Test, der mitnetworkmarkiert ist, wenn eine zweite Option,--offline, gegeben ist. - Lade es mit
-p pytest_budget, und schreib einen async Test im auto-Modus, derawait asyncio.sleep(...)für weniger alsbudgetwartet und prüft, dass er innerhalb des Budgets fertig wird.
Dann teste das Plugin mit pytester: ein Test für jede Option, mittels assert_outcomes für die Zahlen und fnmatch_lines für den Header. Zum Schluss brich das Plugin absichtlich — benenne pytest_report_header in pytest_report_headers um — und lies, was pytest sagt. Diesen Fehler einmal zu sehen, macht ihn für immer leicht erkennbar.
Probiere es selbst, bevor du weiterliest.
Lösung
Das Projekt hat dieselbe Form wie das komplette Beispiel:
budget/
├── pyproject.toml
├── pytest_budget.py
└── tests/
├── test_budget_plugin.py
└── test_timing.pypyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_budget"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"pytest_budget.py:
"""Give every test a time budget, and let network tests be switched off."""
import pytest
def pytest_addoption(parser):
parser.addoption("--budget", type=float, default=0.5, help="seconds a test may take")
parser.addoption(
"--offline", action="store_true", default=False, help="skip tests marked network"
)
def pytest_configure(config):
config.addinivalue_line("markers", "network: the test needs a network connection")
def pytest_report_header(config):
return f"budget: {config.getoption('--budget')}s"
def pytest_collection_modifyitems(config, items):
if not config.getoption("--offline"):
return
skip_network = pytest.mark.skip(reason="--offline was given")
for item in items:
if "network" in item.keywords:
item.add_marker(skip_network)
@pytest.fixture
def budget(request):
return request.config.getoption("--budget")tests/test_timing.py:
import asyncio
import time
import pytest
async def test_finishes_within_budget(budget):
start = time.perf_counter()
await asyncio.sleep(budget / 5)
assert time.perf_counter() - start < budget
@pytest.mark.network
async def test_price_service_answers():
await asyncio.sleep(0.01) # stands in for a real request
assert Truetests/test_budget_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_reads_budget(budget):
assert budget == EXPECTED
@pytest.mark.network
def test_network():
assert True
"""
def run(pytester, *args, expected=0.5):
# A fresh inner project: its own config, our sample tests, our plugin.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE.replace("EXPECTED", repr(expected)))
return pytester.runpytest("-p", "pytest_budget", *args)
def test_default_budget(pytester):
result = run(pytester)
result.assert_outcomes(passed=2)
def test_budget_option_is_a_float(pytester):
result = run(pytester, "--budget", "2", expected=2.0)
result.assert_outcomes(passed=2)
def test_offline_skips_network_tests(pytester):
result = run(pytester, "--offline")
result.assert_outcomes(passed=1, skipped=1)
def test_header_shows_budget(pytester):
result = run(pytester, "--budget", "1.5", expected=1.5)
result.stdout.fnmatch_lines(["budget: 1.5s"])
def test_marker_is_registered(pytester):
result = run(pytester, "--markers")
result.stdout.fnmatch_lines(["@pytest.mark.network: *"])pytest -v (Header gekürzt):
budget: 0.5s
collecting ... collected 7 items
tests/test_budget_plugin.py::test_default_budget PASSED [ 14%]
tests/test_budget_plugin.py::test_budget_option_is_a_float PASSED [ 28%]
tests/test_budget_plugin.py::test_offline_skips_network_tests PASSED [ 42%]
tests/test_budget_plugin.py::test_header_shows_budget PASSED [ 57%]
tests/test_budget_plugin.py::test_marker_is_registered PASSED [ 71%]
tests/test_timing.py::test_finishes_within_budget PASSED [ 85%]
tests/test_timing.py::test_price_service_answers PASSED [100%]
============================== 7 passed in 0.31s ===============================pytest -q --offline -rs:
......s [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_timing.py:13: --offline was given
6 passed, 1 skipped in 0.57sUnd mit dem Hook umbenannt zu pytest_report_headers, läuft gar nichts:
INTERNALERROR> pluggy._manager.PluginValidationError: unknown hook 'pytest_report_headers' in plugin <module 'pytest_budget' from '/home/you/budget/pytest_budget.py'>
no tests ran in 0.00sWarum jede Entscheidung:
type=float. Ohne ihn käme--budget 2als String"2"an, undbudget / 5würdeTypeErrorwerfen.test_budget_option_is_a_floatexistiert, um das festzulegen: der innere Test prüftbudget == 2.0, und der String"2"wäre nicht gleich.- Die Fixture ist im Plugin definiert. Ein Plugin-Modul kann Fixtures definieren, genau wie eine
conftest.py, sodass jeder, der-p pytest_budgetlädt, auchbudgetbekommt. Tests fragen nachbudget; keines davon weiß, dass eine Option dahintersteckt. - Der Skip wird bei der Collection entschieden, nicht im Test.
test_price_service_answersenthält keinif. Ein Hook hält die Regel, und ein neuernetworkTest ist abgedeckt, sobald er geschrieben ist. - Der Timing-Test arbeitet mit einem Puffer, keine Zahl. Er schläft ein Fünftel des Budgets und prüft, dass er innerhalb des ganzen Budgets fertig wurde. Das funktioniert auf einer langsamen CI-Maschine; „took 0.1 seconds“ würde nicht.
- Eine
runHilfsfunktion. JederpytesterTest baut dasselbe innere Projekt, also wird das Setup — einschließlich dermakeiniZeile, diepytest-asyncioim inneren Lauf Warnungen unterdrückt — einmal geschrieben. Der erste Entwurf dieser Lösung vergaß den Helper intest_marker_is_registered, und der äußere Lauf meldete prompt1 warningvon diesem einen inneren Lauf. - Zahlen für Verhalten, Zeilen für Ausgabe.
assert_outcomesprüft, was das Plugin mit Tests tut;fnmatch_linesprüft, was es ausgibt. Der*in"@pytest.mark.network: *"hält den Test davon ab, zu brechen, wenn die Marker-Beschreibung umformuliert wird. - Der kaputte Hook. pytest prüft jede Funktion, deren Name mit
pytest_beginnt, gegen die Hooks, die es kennt, und weigert sich zu starten, wenn einer nicht passt. Ein Tippfehler in einem Hook-Namen kann nicht stillschweigend nichts tun — und diepytesterTests hätten ihn sowieso gefangen, datest_header_shows_budgetnach einer Zeile sucht, die nie gedruckt würde.
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