Async tests, hooks en een plugin schrijven
Async code testen met pytest-asyncio, daarna pytest uitbreiden met hooks in conftest.py — een --runslow vlag, een marker, een rapportheader — en tenslotte dat omzetten in een eigen plugin en hem testen met pytester.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een klein stukje asynchrone code — de soort die met een netwerk praat en niet stil wil zitten te wachten.
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]Je test het op de manier waarop je alles tot nu toe hebt getest: een functie wiens naam begint met test_. Het is een async functie, omdat await alleen daarin mag staan. En om zeker te zijn dat de test echt iets controleert, is de verwachte prijs bewust fout:
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.11sLees de foutmelding goed. Het zegt niets over 15.0 of 999.0. De assertie werd nooit uitgevoerd. Een async def functie aanroepen voert de body niet uit — het creëert alleen een coroutine, en iets moet een event loop aansturen om hem te draaien. Gewone pytest doet dat niet. (Oudere pytest-versies slaagden zo'n test over met een waarschuwing, zodat het groen leek terwijl er niets getest werd. Pytest 9 laat hem falen, wat veel beter is.)
Dat is de eerste helft van dit hoofdstuk: pytest asynchrone code laten uitvoeren. De tweede helft is het diepere idee achter de oplossing — pytest is zelf een verzameling hooks, en een plugin is gewoon code die er een paar van invult. Zodra je dat ziet, kun je je eigen plugin schrijven: een --runslow vlag, een marker, een regel in de rapportheader, en een testsuite voor de plugin zelf.
Aan het einde van dit hoofdstuk kun je
- Uitleggen waarom een kale
async def test_...niet draait, en de fout herkennen - Async tests draaien met
pytest-asyncio, via@pytest.mark.asyncioofasyncio_mode = "auto" - Async fixtures schrijven, inclusief met afbouw na
yield - Een commandoregeloptie toevoegen met
pytest_addoptionen uitlezen metrequest.config.getoption - Langzame tests overslaan tenzij
--runslowgegeven wordt, metpytest_collection_modifyitems - Een marker registreren in
pytest_configureen een regel toevoegen metpytest_report_header - Weten waar pytest hooks vindt:
conftest.py,-p, enpytest11entry points - Een plugin testen met de
pytesterfixture
Voorkennis: coverage en CI.
Voordat je de test schrijft
In dit hoofdstuk test je twee verschillende dingen — een async functie en een pytest plugin — en beide vragen om nadenken voordat je de eerste testregel schrijft.
Het contract. Schrijf het contract eerst in platte taal, want de tests zijn dat contract, vertaald.
fetch_price(item)is een coroutine functie. Als je hem await met een bekend item, levert hij de prijs van dat item op. Als je hem await met een onbekend item, gooit hijKeyError. Veel gelijktijdige calls moeten overlappen, niet in een wachtrij staan.- De slow-test plugin belooft: zonder
--runslowwordt elke test met de markeringslowovergeslagen, met een reden; met--runslowdraait alles; de header zegt welke; en deslowmarker is geregistreerd, dus geen waarschuwing.
Wat er klaar moet staan. Async tests hebben een runner nodig; pytest heeft er geen: installeer pytest-asyncio en kies voor strict of auto mode voordat je tests schrijft, want de decorators die je schrijft hangen ervan af. De code onder test moet importeerbaar zijn — prices.py in de projectroot, en later de plugin module ook (pythonpath = ["."]). Het testen van de plugin vereist dat de pytester fixture ingeschakeld is. En niets mag een echt netwerk nodig hebben: asyncio.sleep doet ervoor.
Het plan. Eén rij per gedrag, elk met invoer en wat je verwacht te zien:
| Geval | Invoer | Verwacht | | --- | --- | --- | | bekend item | await fetch_price("pen") | 15.0 | | onbekend item | await fetch_price("stapler") | KeyError | | await vergeten | fetch_price("pen") in een gewone test | een coroutine object, geen prijs | | veel tegelijk | 400 calls via asyncio.gather | alle 400 terug, in ongeveer de tijd van één | | standaard run | pytest met één slow test | 1 passed, 1 skipped | | vlag gegeven | pytest --runslow | 2 passed | | header | pytest | een regel slow tests: skipped (use --runslow) | | marker | pytest --markers | slow in de lijst, geen PytestUnknownMarkWarning |
Wat je niet test. Geen asyncio.sleep, geen event loop — Python test dit zelf. Geen skip-mechanisme van pytest zelf: je test dat jouw hook de skip toevoegt, door uitkomsten te tellen, niet hoe pytest een s print. Ook geen exacte tijden: "400 calls klaar in onder een seconde" is een stabiele claim op elke machine, "in 0.013 seconden" niet.
De rest van dit hoofdstuk werkt die tabel af, rij voor rij.
Async tests draaien met pytest-asyncio
De foutmelding noemt verschillende plugins. Voor code gebouwd op asyncio is de gebruikelijke keuze pytest-asyncio:
pip install pytest-asyncioAlleen installeren is niet genoeg. Draai dezelfde test nog eens en de output is woord voor woord hetzelfde: async def functions are not natively supported. Standaard werkt pytest-asyncio in strict mode — het handelt alleen tests af die je hem expliciet geeft. Je geeft er één door met een 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.10sNu faalt het om de juiste reden. assert 15.0 == 999.0 — de coroutine draaide, await leverde een echt getal op, en de assertie vergelijkte het. Een test zien falen met de verkeerde waarde is het bewijs dat hij echt draait. Zet het getal goed, en voeg een test voor het foutpad toe terwijl je er bent:
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 werkt binnen een async test precies zoals elders; de await gaat gewoon in het with blok.
Er is nog één foute manier die de moeite waard is om te zien, omdat het de meest voorkomende is: het probleem omzeilen door een gewone def test te schrijven en de async functie aan te roepen zonder await.
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 awaitedHet rapport zegt precies wat er gebeurde: fetch_price('pen') leverde een coroutine object op, en een coroutine is niet gelijk aan 15.0. Python voegt toe dat de coroutine weggegooid werd zonder ooit te draaien. Deze faalt tenminste duidelijk. De gevaarlijke variant is assert fetch_price("pen") zonder vergelijking — een coroutine object is truthy, dus die test rapporteert 1 passed, 1 warning terwijl hij niets controleert. Elke test van async code moet zelf async def zijn en moet await gebruiken.
asyncio_mode = "auto" en async fixtures
@pytest.mark.asyncio boven elke test wordt snel vervelend. In een project waar async het normaal geval is, zet je de mode in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"In auto mode pakt pytest-asyncio elke async def test en elke async def fixture, zonder marker. De header van een volledige pytest run vertelt je welke mode actief is:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionFixtures kunnen ook async zijn. Hier wordt een winkelkar opgestart, aan de test doorgegeven, en daarna afgesloten — met een await aan beide kanten van de 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.11sDe yield werkt als in de fixture-hoofdstukken: alles ervoor is opzet, alles erna is afbouw, en afbouw draait zelfs als de test faalt.
In strict mode heeft dezelfde fixture zijn eigen decorator nodig, @pytest_asyncio.fixture, vanuit import pytest_asyncio. Met een kale @pytest.fixture weet pytest niet hoe hij hem moet draaien en stopt hij al bij de opzet:
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.09sDus de keuze is simpel. Strict mode: markeer async tests met @pytest.mark.asyncio en async fixtures met @pytest_asyncio.fixture. Auto mode: kale async def overal. Kies er één per project en zet het in de config zodat niemand hoeft te gokken.
Hoe deed pytest-asyncio dat? Het veranderde pytest niet. Het implementeerde een paar van pytest's hooks — functies met vaste namen die pytest op vaste momenten aanroept, zoals "een test gaat draaien". Dat is alles wat een plugin is. De rest van dit hoofdstuk schrijft een plugin van jezelf.Hooks in conftest.py
Een hook is een functie genaamd pytest_<iets> die pytest aanroept op een bepaald moment van een run. Je registreert hem niet; je definieert hem op een plek waar pytest kijkt, en de naam doet de rest. De eerste zo'n plek is het conftest.py bestand dat je al gebruikt voor gedeelde fixtures.
Een commandoregeloptie: pytest_addoption
Stel de dezelfde tests moeten draaien tegen je laptop en tegen een staging server. Het adres hoort op de commandoregel, niet in de 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, daarna 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 draait één keer, voordat de commandoregel geparst wordt, en parser.addoption neemt dezelfde argumenten als Pythons argparse. Vervolgens is request.config het configuratieobject van de run, en getoption leest de waarde terug. De optie verschijnt ook in pytest --help:
--shop-url=SHOP_URL where the shop under test runsDe optie in een fixture zetten is het gebruikelijke patroon: tests vragen shop_url en raken de configuratie nooit zelf aan.
Langzame tests overslaan: pytest_collection_modifyitems
Een veelvoorkomend behoefte: sommige tests duren seconden, en je wilt ze niet bij elke save — alleen voor een push, of in CI. Markeer ze slow, en sla ze over tenzij een --runslow vlag gegeven wordt.
De verleidelijke eerste poging is de check binnen elke slow test te zetten: de optie bovenaan uitlezen, pytest.skip() aanroepen als hij ontbreekt. Het werkt, en het is de verkeerde manier. De regel zit nu in elke slow test in plaats van op één plek; de dag dat iemand een nieuwe slow test schrijft en die twee regels vergeet, draait hij bij elke save en niemand ziet waarom de suite langzamer werd. En de test body moet eerst draaien voordat hij kan besluiten niet te draaien, dus zijn fixtures zijn al opgezet — misschien een database, misschien een server.
De juiste plek is voordat enige test begint. Nadat pytest elke test heeft verzameld, en voordat er een draait, roept het pytest_collection_modifyitems(config, items) aan. items is de lijst met verzamelde tests, en de hook mag hem veranderen — één regel, op één plek, toegepast op elke test die de marker draagt.
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, daarna pytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sZonder de vlag is de slow test een s en duurt de run een tiende seconde. Met de vlag draaien beide en kost de tweede zijn volledige seconde. action="store_true" maakt --runslow een schakelaar: afwezig betekent False, aanwezig betekent True. Binnen een hook is er geen request, dus de hook leest de waarde direct uit config.
item.keywords bevat de namen van de markers van de test (onder andere), dus "slow" in item.keywords vraagt "draagt deze test @pytest.mark.slow?". item.add_marker(skip_slow) hangt een skip aan hem, precies alsof je @pytest.mark.skip(reason="needs --runslow") boven de test had gezet.
De marker registreren en een headerregel toevoegen
Twee kleinere hooks in dat bestand verdienen een woord.
pytest_configure(config) draait één keer, nadat de commandoregel geparst is. config.addinivalue_line("markers", ...) registreert de slow marker — hetzelfde als een markers = [...] regel in pyproject.toml, maar gedragen door de plugin zelf. Zonder het produceert elke @pytest.mark.slow een PytestUnknownMarkWarning, en onder --strict-markers wordt het een fout. Nu lijst pytest --markers hem eerst:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) geeft een string (of lijst van strings) terug die pytest bovenaan een run print. Het is de juiste plek om alles te vermelden wat een lezer van een CI-log moet weten. 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 =========================Een overgeslagen test met een duidelijke reden is eerlijk: de samenvatting zegt wat niet draaide, en waarom.
Hoe pytest hooks vindt
Elke van deze hooks woont in een module. Pytest verzamelt zo'n modules — plugins — uit verschillende plaatsen:
- Zijn eigen ingebouwde plugins. Het grootste deel van pytest is als plugins geschreven:
-k, markers,tmp_path, het terminalrapport. - Geïnstalleerde pakketten die een
pytest11entry point declareren. Zo ladenpytest-asyncio,pytest-coven de rest zichzelf zodra ze geïnstalleerd zijn. Deplugins:regel in de header lijst ze. - Modules genoemd met
-pop de commandoregel, of inaddopts. conftest.pybestanden, in de root van je tests en in elke submap (een submap'sconftest.pygeldt alleen voor tests eronder). Een paar vroege hooks, zoalspytest_addoption, worden alleen geëerd in eenconftest.pyin de root.
Dus de conftest.py hierboven kan als-is meegepakt worden. Hernoem hem naar pytest_slowtests.py, en het is een plugin. Laad hem met -p en de modulenaam:
pytest -p pytest_slowtestsDe module moet importeerbaar zijn. Als hij in de projectroot zit, vertel je pytest dat met pythonpath, en om -p niet elke keer te typen, zet je het in addopts:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"Om het over projecten te delen, maak er een pakket van en declareer de entry point. De groep moet pytest11 heten; de naam links is de plugin naam, en de waarde rechts is de module:
[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"Installeer het (pip install ./pytest-slowtests) in een verse omgeving, en in een project zonder enig 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 vond hem via de entry point. Het omgekeerde werkt ook: -p no:slowtests zet een geïnstalleerde plugin uit voor één run, handig wanneer je een plugin van problemen verdacht.
Een plugin testen met pytester
Een plugin verandert hoe pytest zich gedraagt, dus zijn tests moeten pytest draaien en kijken wat er gebeurde. Pytest levert daar precies een fixture voor, pytester. Hij staat standaard uit; zet hem aan in de testmodule (of in de root conftest.py):
pytest_plugins = ["pytester"]pytester geeft elke test een lege tijdelijke map, een vers projectje. pytester.makepyfile(...) schrijft een testbestand erin, pytester.runpytest(...) draait pytest daar met de argumenten die je geeft, en het resultaat heeft assert_outcomes(passed=..., skipped=..., failed=...) om de tellingen te checken, en result.stdout.fnmatch_lines([...]) om outputregels te checken (* mag als wildcard). De volledige versie staat in het complete voorbeeld hieronder.
Een compleet voorbeeld
Eén klein project dat alles uit dit hoofdstuk bevat: async code, async tests in auto mode, de slow-test plugin, en tests voor de plugin.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py is die van het begin van het hoofdstuk, en pytest_slowtests.py is de conftest.py uit de --runslow sectie, hernoemd. 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 ingekort):
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 =========================En pytest -q --runslow:
...... [100%]
6 passed in 0.40sDrie dingen zijn het vermelden waard.
Eerst werkt de plugin op async tests ook. test_every_price_at_once is een async def met een slow marker, en de skip wordt erop toegepast als op elke andere test. Hooks zien verzamelde items; of de functie erachter async is, is pytest-asyncio's zaak, niet die van de hooks.
Tweedens, asyncio.gather draaide 400 calls van fetch_price, elk slapend 0.01 seconden, en de hele run duurde wel onder een seconde — niet vier. Ze wachtten tegelijkertijd. Dat is de reden waarom async code bestaat, en de test bewijst dat het zich zo gedraagt.
Derde, de makeini regel in run. De inner run binnen pytester is een apart project met eigen configuratie, en het laadt elke geïnstalleerde plugin — inclusief pytest-asyncio, die waarschuwt wanneer asyncio_default_fixture_loop_scope niet ingesteld is. Die ene instelling aan het inner project geven houdt het outer rapport schoon. De algemene les: een pytester run ziet je geïnstalleerde plugins, maar niet je pyproject.toml.
Wanneer de plugin breekt, zeggen deze tests het precies. Hier was de skip check vergist als "slw", dus niets werd overgeslagen:
> 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 diffOnder dat, onder Captured stdout call, print pytest de hele output van de inner run, zodat je precies ziet wat hij deed.
Wanneer het misgaat
async def functions are not natively supported. De test is een async def, en niets draait hem. Of pytest-asyncio is niet geïnstalleerd, of hij is in strict mode geïnstalleerd en de test mist @pytest.mark.asyncio. Voeg de marker toe, of zet asyncio_mode = "auto".
requested an async fixture 'cart', with no plugin or hook that handled it Een async def fixture is gedecoreerd met kale @pytest.fixture in strict mode. Gebruik @pytest_asyncio.fixture, of schakel over naar auto mode.
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, gevolgd door RuntimeWarning: coroutine 'fetch_price' was never awaited Een gewone def test roept een async functie aan zonder await, en vergelijkt het coroutine object zelf. Maak de test async def en schrijf await fetch_price("pen").
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? De marker wordt gebruikt maar nooit geregistreerd. Registreer hem in pytest_configure met config.addinivalue_line("markers", ...), of in de markers instelling.
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> Een functie begint met pytest_ maar is geen hook die pytest kent — hier een verlopen s. Pytest checkt elke pytest_ naam in een plugin, wat precies is wat de typfout vangt. Controleer de spelling tegen de hook referentie.
ValueError: no option named '--runslow' config.getoption vraagt om een optie die geen pytest_addoption toevoegde. Of de naam is anders, of de pytest_addoption hook zit in een conftest.py die pytest niet vroeg genoeg laadde — houd hem in de root van je tests.
pytest: error: unrecognized arguments: --run-slow De optie op de commandoregel matcht niet met de geregistreerde. pytest --help lijst elke optie die je plugins toegevoegd hebben, onder "custom options".
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p neemt een modulenaam, en die module kon niet geïmporteerd worden. Voeg pythonpath = ["."] toe aan de config, of installeer de plugin als pakket.
Stap 4 van 6 — Voorspellen
Test je begrip
fetch_price("pen") levert echt 15.0 op. Wat rapporteert 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 mode ERRORt deze test bij setup. Wat is de fix?
# 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)]- AVervang `yield` door `return`
- BDecoreer de fixture met `@pytest_asyncio.fixture` (of zet `asyncio_mode = "auto"`)
- CVerwijder `@pytest.mark.asyncio` van de test
- DMaak `test_add` een gewone `def` test
Beide bestanden staan in één map. Wat toont pytest -q, zonder vlaggen?
# 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
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
Schrijf een plugin, pytest_budget.py, die elke test een tijdsbudget geeft.
- Voeg een optie
--budgettoe met standaard0.5(seconden); gebruiktype=floatinparser.addoption. - Lever een fixture
budgetdie de waarde teruggeeft viarequest.config.getoption("--budget"). - Registreer een marker
networkinpytest_configure, en printbudget: 0.5s(of wat ook gegeven was) metpytest_report_header. - In
pytest_collection_modifyitems, sla elke test gemarkeerdnetworkover wanneer een tweede optie,--offline, gegeven is. - Laad hem met
-p pytest_budget, en schrijf een async test in auto mode dieawait asyncio.sleep(...)doet voor minder danbudgeten bevestigt dat hij op tijd klaar is.
Test daarna de plugin met pytester: één test per optie, met assert_outcomes voor de tellingen en fnmatch_lines voor de header. Ten slotte, breekt de plugin exprès — hernoem pytest_report_header naar pytest_report_headers — en lees wat pytest zegt. Die fout een keer zien maakt hem gemakkelijk te herkennen.
Probeer het zelf voordat je verder leest.
Oplossing
Het project heeft dezelfde vorm als het complete voorbeeld:
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 ingekort):
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.57sEn met de hook hernoemd naar pytest_report_headers, draait helemaal niets:
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.00sWaarom elke keuze:
type=float. Zonder hem komt--budget 2als de string"2"binnen, enbudget / 5gooitTypeError.test_budget_option_is_a_floatbestaat om dit vast te pinnen: de inner test bevestigtbudget == 2.0, en de string"2"zou niet gelijk zijn.- De fixture zit in de plugin. Een plugin module kan fixtures definiëren net als een
conftest.py, dus iedereen die-p pytest_budgetlaadt krijgtbudgetmee. Tests vragenbudget; geen van hen weet dat er een optie achter zit. - De skip wordt bij verzameling beslist, niet in de test.
test_price_service_answersbevat geenif. Één hook houdt de regel, en een nieuwenetworktest is gedekt zodra hij geschreven is. - De timing test claimt een marge, geen getal. Hij slaapt een vijfde van het budget en bevestigt dat hij binnen het hele budget klaar is. Dat houdt op een trage CI machine; "duurde 0.1 seconden" zou niet.
- Één
runhelper. Elkepytestertest bouwt hetzelfde inner project, dus de opzet — inclusief demakeiniregel diepytest-asynciostil houdt in de inner run — wordt één keer geschreven. De eerste versie van deze oplossing vergat de helper intest_marker_is_registered, en de outer run rapporteerde direct1 warningvan die ene inner run. - Tellingen voor gedrag, regels voor output.
assert_outcomescheckt wat de plugin doet met tests;fnmatch_linescheckt wat hij print. De*in"@pytest.mark.network: *"houdt de test van breken als de marker beschrijving herschreven wordt. - De gebroken hook. Pytest checkt elke functie wiens naam begint met
pytest_tegen de hooks die hij kent, en weigert te starten als er eentje niet matcht. Een typfout in een hooknaam kan niet stilletjes niets doen — en depytestertests zouden het ook gevangen hebben, omdattest_header_shows_budgetzoekt naar een regel die nooit geprint zou worden.
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