Test asincroni, hook e scrittura di un plugin
Testare codice asincrono con pytest-asyncio, poi estendere pytest con hook in conftest.py — un flag --runslow, un marker, un header del report — e infine trasformarlo in un plugin tuo e testarlo con pytester.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco un piccolo pezzo di codice asincrono — il tipo che parla con la rete e non vuole restare in attesa inattivo.
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]Lo testi come hai testato tutto finora: una funzione il cui nome inizia con test_. È una funzione async, perché await è permesso solo dentro una. E per essere sicuri che il test controlli davvero qualcosa, il prezzo atteso è volutamente sbagliato:
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.11sLeggi il fallimento con attenzione. Non dice nulla di 15.0 o 999.0. L'assertion non è mai stata eseguita. Chiamare una funzione async def non esegue il suo corpo — crea solo una coroutine, e qualcosa deve guidare un event loop per eseguirla. Pytest semplice non lo fa. (Le versioni vecchie di pytest saltavano un test così con un avviso, quindi sembrava verde mentre non testava nulla. Pytest 9 lo fa fallire, che è molto meglio.)
Questa è la prima metà del capitolo: far eseguire codice asincrono a pytest. La seconda metà è l'idea più profonda dietro la correzione — pytest stesso è un insieme di hook, e un plugin è solo codice che ne riempie alcuni. Una volta che vedi questo, puoi scrivere i tuoi: un flag --runslow, un marker, una riga nell'header del report, e una suite di test per il plugin stesso.
Alla fine di questo capitolo sarai in grado di
- Spiegare perché un
async def test_...senza marker non viene eseguito, e riconoscere il fallimento - Eseguire test asincroni con
pytest-asyncio, usando@pytest.mark.asynciooasyncio_mode = "auto" - Scrivere fixture asincrone, incluse quelle con teardown dopo
yield - Aggiungere un'opzione da riga di comando con
pytest_addoptione leggerla conrequest.config.getoption - Saltare i test lenti a meno che non sia dato
--runslow, usandopytest_collection_modifyitems - Registrare un marker in
pytest_configuree aggiungere una riga conpytest_report_header - Dire dove pytest trova gli hook:
conftest.py,-p, e entry pointpytest11 - Testare un plugin con la fixture
pytester
Prerequisiti: coverage e CI.
Prima di scrivere il test
Questo capitolo testa due tipi diversi di cose — una funzione asincrona e un plugin pytest — e entrambi hanno bisogno di un po' di pensiero prima della prima riga di codice di test.
Cosa viene promesso. Scrivi il contratto a parole semplici prima, perché i test sono quel contratto, tradotto.
fetch_price(item)è una funzione coroutine. Quando viene attesa con un articolo noto, restituisce il prezzo di quell'articolo. Quando viene attesa con un articolo sconosciuto, sollevaKeyError. Molte chiamate attese insieme dovrebbero sovrapporsi, non mettersi in coda.- Il plugin per test lenti promette: senza
--runslow, ogni test marcatoslowviene saltato, con un motivo; con--runslow, tutto gira; l'header dice quali; e il markerslowè registrato, quindi non solleva avvisi.
Cosa deve essere pronto. I test asincroni hanno bisogno di un runner, che pytest non ha: installa pytest-asyncio e decidi tra modalità strict o auto prima di scrivere test, perché i decoratori che scrivi dipendono da quella. Il codice sotto test deve essere importabile — prices.py alla radice del progetto, e dopo anche il modulo del plugin (pythonpath = ["."]). Testare il plugin richiede la fixture pytester attivata. E nulla deve aver bisogno di una rete vera: asyncio.sleep sta al posto di una.
Il piano. Una riga per comportamento, ognuna con il suo input e cosa ti aspetti di vedere:
| Caso | Input | Atteso | | --- | --- | --- | | articolo noto | await fetch_price("pen") | 15.0 | | articolo sconosciuto | await fetch_price("stapler") | KeyError | | await dimenticato | fetch_price("pen") in un test normale | un oggetto coroutine, non un prezzo | | molti insieme | 400 chiamate attraverso asyncio.gather | tutte 400 tornano, in circa il tempo di una | | esecuzione di default | pytest con un test slow | 1 passed, 1 skipped | | flag dato | pytest --runslow | 2 passed | | header | pytest | una riga slow tests: skipped (use --runslow) | | marker | pytest --markers | slow elencato, niente PytestUnknownMarkWarning |
Cosa non testare. Non asyncio.sleep, né l'event loop — Python se ne occupa. Non il meccanismo di skip di pytest: testi che il tuo hook applica lo skip, contando gli esiti, non come pytest stampa una s. Non i tempi esatti, neanche: "400 chiamate finiscono in meno di un secondo" è un'affermazione stabile su qualsiasi macchina, mentre "in 0.013 secondi" non lo è.
Il resto del capitolo lavora su quella tabella, riga per riga.
Eseguire test async con pytest-asyncio
Il messaggio di fallimento elenca diversi plugin. Per codice costruito su asyncio, la scelta solita è pytest-asyncio:
pip install pytest-asyncioInstallarlo non basta da solo. Riesegui lo stesso test e l'output è identico parola per parola: async def functions are not natively supported. Di default pytest-asyncio lavora in modalità strict — gestisce solo i test che gli hai passato esplicitamente. Ne passi uno con un 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.10sOra fallisce per la ragione giusta. assert 15.0 == 999.0 — la coroutine è stata eseguita, await ha prodotto un vero numero, e l'assertion l'ha confrontato. Vedere un test fallire con il valore sbagliato è la prova che viene davvero eseguito. Correggi il numero, e aggiungi un test per il percorso d'errore mentre ci sei:
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 funziona dentro un test async esattamente come altrove; l'await va semplicemente dentro il blocco with.
C'è un altro modo sbagliato che vale la pena vedere, perché è il più comune: evitare del tutto il problema scrivendo un test def normale e chiamando la funzione asincrona senza 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 awaitedIl report dice esattamente cosa è successo: fetch_price('pen') ha restituito un oggetto coroutine, e una coroutine non è uguale a 15.0. L'ultima riga è Python che aggiunge che la coroutine è stata scartata senza mai essere eseguita. Questo almeno fallisce in modo evidente. La variante pericolosa è assert fetch_price("pen") senza confronto — un oggetto coroutine è truthy, quindi quel test riporta 1 passed, 1 warning mentre non controlla nulla. Qualsiasi test di codice asincrono deve essere esso stesso async def e deve await.
asyncio_mode = "auto" e fixture asincrone
Scrivere @pytest.mark.asyncio sopra ogni test diventa noioso rapidamente. In un progetto dove asincrono è il caso normale, cambia la modalità in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"In modalità auto, pytest-asyncio prende ogni test async def e ogni fixture async def, senza marker. L'header di un pytest completo ti dice quale modalità è in vigore:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionAnche le fixture possono essere asincrone. Qui un carrello viene preparato, passato al test, e chiuso dopo — con un await da entrambi i lati dello 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.11sLo yield funziona come nel capitolo sui fixture: tutto prima è setup, tutto dopo è teardown, e il teardown viene eseguito anche quando il test fallisce.
In modalità strict, la stessa fixture ha bisogno del suo decoratore, @pytest_asyncio.fixture, da import pytest_asyncio. Con un @pytest.fixture semplice lì, pytest non sa come eseguirla e si ferma allo 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.09sQuindi la scelta è semplice. Modalità strict: marca i test asincroni con @pytest.mark.asyncio e le fixture asincrone con @pytest_asyncio.fixture. Modalità auto: async def semplice ovunque. Scegline una per progetto e scrivila nel config così nessuno deve indovinare.
Come ha fatto pytest-asyncio a fare questo? Non ha cambiato pytest. Ha implementato alcuni degli hook di pytest — funzioni con nomi fissi che pytest chiama in momenti fissi, come "un test sta per essere eseguito". Questo è tutto ciò che un plugin è. Il resto del capitolo scrive un plugin tuo.Hook in conftest.py
Un hook è una funzione chiamata pytest_<qualcosa> che pytest chiama in un momento particolare di un'esecuzione. Non lo registri; lo definisci in un posto che pytest guarda, e il nome fa il resto. Il primo posto simile è il file conftest.py che usi già per fixture condivisi.
Un'opzione da riga di comando: pytest_addoption
Immagina che gli stessi test debbano essere eseguiti contro il tuo laptop e contro un server di staging. L'indirizzo sta sulla riga di comando, non nel codice:
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, poi 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 viene eseguito una volta, prima che la riga di comando sia parsata, e parser.addoption prende gli stessi argomenti di argparse di Python. Poi request.config è l'oggetto configurazione dell'esecuzione, e getoption legge il valore. L'opzione appare anche in pytest --help:
--shop-url=SHOP_URL where the shop under test runsAvvolgere l'opzione in una fixture è il pattern abituale: i test chiedono shop_url e non toccano mai la configurazione direttamente.
Saltare i test lenti: pytest_collection_modifyitems
Una necessità comune: alcuni test impiegano secondi, e non li vuoi a ogni salvataggio — solo prima di un push, o in CI. Marcali slow, e saltali a meno che un flag --runslow non sia dato.
La prima soluzione allettante è mettere il controllo dentro ogni test lento: leggi l'opzione in cima, chiama pytest.skip() se manca. Funziona, ed è la via sbagliata. La regola ora vive in ogni test lento invece che in un unico posto; il giorno in cui qualcuno scrive un nuovo test lento e dimentica quelle due righe, viene eseguito a ogni salvataggio e nessuno nota perché la suite è diventata più lenta. E il corpo del test deve iniziare a essere eseguito prima di poter decidere di non eseguirsi, quindi le sue fixture sono già state preparate — forse un database, forse un server.
Il posto giusto è prima che qualsiasi test inizi. Dopo che pytest ha raccolto ogni test, e prima che nessuno venga eseguito, chiama pytest_collection_modifyitems(config, items). items è la lista dei test raccolti, e l'hook può cambiarla — una regola, in un unico posto, applicata a ogni test che porta il marker.
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, poi pytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sSenza il flag, il test lento è una s e l'esecuzione impiega un decimo di secondo. Con esso, tutti e due girano e il secondo impiega il suo secondo intero. action="store_true" fa di --runslow un interruttore: assente vuol dire False, presente vuol dire True. Dentro un hook non c'è request, quindi l'hook legge il valore direttamente da config.
item.keywords contiene i nomi dei marker del test (tra le altre cose), quindi "slow" in item.keywords chiede "questo test porta @pytest.mark.slow?". item.add_marker(skip_slow) gli applica uno skip, esattamente come se avessi scritto @pytest.mark.skip(reason="needs --runslow") sopra il test tu stesso.
Registrare il marker e aggiungere una riga nell'header
Due hook più piccoli in quel file meritano una parola.
pytest_configure(config) viene eseguito una volta, dopo che la riga di comando è parsata. config.addinivalue_line("markers", ...) registra il marker slow — come una riga markers = [...] in pyproject.toml, ma fornito dal plugin stesso. Senza, ogni @pytest.mark.slow produce PytestUnknownMarkWarning, e sotto --strict-markers diventa un errore. Ora pytest --markers lo elenca per primo:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) restituisce una stringa (o una lista di stringhe) che pytest stampa in cima a un'esecuzione. È il posto giusto per dire qualsiasi cosa un lettore di un log CI deve sapere. 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 =========================Un test saltato con un motivo chiaro è onesto: il riassunto dice cosa non è stato eseguito, e perché.
Come pytest trova gli hook
Ognuno di questi hook vive in un modulo. Pytest raccoglie tali moduli — plugin — da diversi posti:
- I suoi plugin built-in. La maggior parte di pytest è scritta come plugin:
-k, marker,tmp_path, il report terminale. - Pacchetti installati che dichiarano un entry point
pytest11. Cosìpytest-asyncio,pytest-cove gli altri si caricano da soli appena installati. La rigaplugins:dell'header li elenca. - Moduli nominati con
-psulla riga di comando, o inaddopts. - File
conftest.py, alla radice dei tuoi test e in qualsiasi sottodirectory (ilconftest.pydi una sottodirectory vale solo per i test sotto di essa). Alcuni hook precoci, comepytest_addoption, sono attivi solo in unconftest.pyalla radice.
Quindi il conftest.py sopra può essere spostato com'è. Rinominalo pytest_slowtests.py, ed è un plugin. Caricalo con -p e il nome del modulo:
pytest -p pytest_slowtestsIl modulo deve essere importabile. Se sta alla radice del progetto, dillo a pytest con pythonpath, e per evitare di digitare -p ogni volta, mettilo in addopts:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"Per condividerlo tra progetti, fanne un pacchetto e dichiara l'entry point. Il gruppo deve chiamarsi pytest11; il nome a sinistra è il nome del plugin, e il valore a destra è il modulo:
[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"Installalo (pip install ./pytest-slowtests) in un ambiente fresco, e in un progetto senza conftest.py affatto:
============================= 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 l'ha trovato attraverso l'entry point. Il contrario funziona anche: -p no:slowtests spegne un plugin installato per un'esecuzione, utile quando sospetti che un plugin causi problemi.
Testare un plugin con pytester
Un plugin cambia come si comporta pytest, quindi i suoi test devono eseguire pytest e guardare cosa è successo. Pytest fornisce una fixture esattamente per questo, pytester. È disattivata di default; attivala nel modulo di test (o nel conftest.py radice):
pytest_plugins = ["pytester"]pytester dà a ogni test una directory temporanea vuota, un piccolo progetto fresco. pytester.makepyfile(...) scrive un file di test dentro, pytester.runpytest(...) esegue pytest lì con gli argomenti che dai, e il risultato ha assert_outcomes(passed=..., skipped=..., failed=...) per controllare i contatori, e result.stdout.fnmatch_lines([...]) per controllare righe di output (* può essere usato come wildcard). La versione completa è nell'esempio completo sotto.
Un esempio completo
Un piccolo progetto che contiene tutto questo capitolo: codice asincrono, test asincroni in modalità auto, il plugin per test lenti, e test per il plugin.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py è quello dall'inizio del capitolo, e pytest_slowtests.py è il conftest.py dalla sezione --runslow, rinominato. 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 abbreviato):
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 =========================E pytest -q --runslow:
...... [100%]
6 passed in 0.40sTre cose meritano di essere notate.
Prima, il plugin funziona anche sui test asincroni. test_every_price_at_once è un async def con un marker slow, e lo skip viene applicato come per qualsiasi altro test. Gli hook vedono item raccolti; se la funzione dietro uno è asincrona sono compito di pytest-asyncio, non loro.
Seconda, asyncio.gather ha eseguito 400 chiamate di fetch_price, ognuna in pausa per 0.01 secondi, e l'intera esecuzione ha impiegato ben meno di un secondo — non quattro. Hanno aspettato contemporaneamente. Questa è la ragione per cui esiste il codice asincrono, e il test prova che si comporta così.
Terza, la riga makeini in run. L'esecuzione interna dentro pytester è un progetto separato con la sua configurazione, e carica ogni plugin installato — inclusi pytest-asyncio, che avvisa quando asyncio_default_fixture_loop_scope non è impostato. Dare a quel progetto interno quell'unica impostazione mantiene pulito il report esterno. La lezione generale: un'esecuzione pytester vede i tuoi plugin installati, ma non il tuo pyproject.toml.
Quando il plugin ha un bug, questi test lo dicono precisamente. Qui il controllo dello skip era scritto male come "slw", quindi nulla veniva saltato:
> 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 diffSotto, sotto Captured stdout call, pytest stampa tutto l'output dell'esecuzione interna, così vedi esattamente cosa ha fatto.
Quando si rompe
async def functions are not natively supported. Il test è un async def, e nulla lo sta eseguendo. O pytest-asyncio non è installato, o è installato in modalità strict e il test manca di @pytest.mark.asyncio. Aggiungi il marker, o imposta asyncio_mode = "auto".
requested an async fixture 'cart', with no plugin or hook that handled it Una fixture async def è decorata con @pytest.fixture semplice in modalità strict. Usa @pytest_asyncio.fixture, o passa alla modalità auto.
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, seguito da RuntimeWarning: coroutine 'fetch_price' was never awaited Un test def normale ha chiamato una funzione asincrona senza await, e ha confrontato l'oggetto coroutine stesso. Rendi il test async def e scrivi await fetch_price("pen").
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Il marker è usato ma mai registrato. Registralo in pytest_configure con config.addinivalue_line("markers", ...), o nell'impostazione markers.
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> Una funzione inizia con pytest_ ma non è un hook che pytest conosce — qui una s di troppo. Pytest controlla ogni nome pytest_ in un plugin, ed è proprio questo che cattura l'errore di battitura. Controlla l'ortografia contro il riferimento degli hook.
ValueError: no option named '--runslow' config.getoption ha chiesto un'opzione che nessun pytest_addoption ha aggiunto. O il nome è diverso, o l'hook pytest_addoption vive in un conftest.py che pytest non ha caricato abbastanza presto — tienilo alla radice dei tuoi test.
pytest: error: unrecognized arguments: --run-slow L'opzione sulla riga di comando non corrisponde a quella registrata. pytest --help elenca ogni opzione che i tuoi plugin hanno aggiunto, sotto "custom options".
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p prende un nome di modulo, e quel modulo non poteva essere importato. Aggiungi pythonpath = ["."] al config, o installa il plugin come pacchetto.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
fetch_price("pen") restituisce davvero 15.0. Cosa riporta 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 modalità strict questo test dà ERROR allo setup. Qual è la correzione?
# 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)]- ASostituisci `yield` con `return`
- BDecora la fixture con `@pytest_asyncio.fixture` (o imposta `asyncio_mode = "auto"`)
- CTogli `@pytest.mark.asyncio` dal test
- DRendi `test_add` un test `def` normale
Entrambi i file stanno in una cartella. Cosa mostra pytest -q, senza flag?
# 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
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi un plugin, pytest_budget.py, che dà a ogni test un budget di tempo.
- Aggiungi un'opzione
--budgetcon default0.5(secondi); usatype=floatinparser.addoption. - Fornisci una fixture
budgetche restituisce il valore viarequest.config.getoption("--budget"). - Registra un marker
networkinpytest_configure, e stampabudget: 0.5s(o quello che è stato dato) conpytest_report_header. - In
pytest_collection_modifyitems, salta ogni test marcatonetworkquando una seconda opzione,--offline, è data. - Caricalo con
-p pytest_budget, e scrivi un test asincrono in modalità auto cheawait asyncio.sleep(...)per meno dibudgete verifica che abbia finito in tempo.
Poi testa il plugin con pytester: un test per ogni opzione, usando assert_outcomes per i contatori e fnmatch_lines per l'header. Infine, rompi il plugin di proposito — rinomina pytest_report_header in pytest_report_headers — e leggi cosa dice pytest. Vedere quell'errore una volta lo rende facile da riconoscere per sempre.
Prova da solo prima di leggere oltre.
Soluzione
Il progetto ha la stessa forma dell'esempio completo:
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 abbreviato):
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.57sE con l'hook rinominato in pytest_report_headers, nulla gira per niente:
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.00sPerché ogni decisione:
type=float. Senza,--budget 2arriva come la stringa"2", ebudget / 5sollevaTypeError.test_budget_option_is_a_floatesiste per fissare questo: il test interno verificabudget == 2.0, e la stringa"2"non sarebbe uguale.- La fixture vive nel plugin. Un modulo plugin può definire fixture proprio come un
conftest.py, quindi chiunque carichi-p pytest_budgetottienebudgetanche. I test chiedonobudget; nessuno di loro sa che c'è un'opzione dietro. - Lo skip è deciso alla collection, non nel test.
test_price_service_answersnon contieneif. Un hook applica la regola, e un nuovo testnetworkè coperto nel momento in cui è scritto. - Il test di timing dichiara un margine, non un numero. Attende un quinto del budget e verifica che abbia finito entro tutto il budget. Questo funziona su una macchina CI lenta; "ci ha messo 0.1 secondi" non funzionerebbe.
- Un solo helper
run. Ogni testpytestercostruisce lo stesso progetto interno, quindi il setup — inclusa la rigamakeiniche tienepytest-asynciosilenzioso nell'esecuzione interna — è scritto una volta. La prima bozza di questa soluzione dimenticava l'helper intest_marker_is_registered, e l'esecuzione esterna riportava prontamente1 warningda quell'unica esecuzione interna. - Contatori per comportamento, righe per output.
assert_outcomescontrolla cosa il plugin fa ai test;fnmatch_linescontrolla cosa stampa. L'*in"@pytest.mark.network: *"impedisce al test di rompersi se la descrizione del marker viene riformulata. - L'hook rotto. Pytest controlla ogni funzione il cui nome inizia con
pytest_contro gli hook che conosce, e rifiuta di partire se uno non corrisponde. Un errore di battitura in un nome di hook non può fare nulla silenziosamente — e i testpytesterl'avrebbero rilevato comunque, visto chetest_header_shows_budgetcerca una riga che non sarebbe mai stampata.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz