Tests async, hooks et écriture d'un plugin
Tester du code async avec pytest-asyncio, puis étendre pytest avec des hooks dans conftest.py — un drapeau --runslow, un marqueur, un en-tête de rapport — et finalement en faire un plugin à vous et le tester avec pytester.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici un petit morceau de code asynchrone — le genre qui parle au réseau et ne veut pas rester inactif en attendant.
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]Vous le testez de la même façon que tout le reste jusqu'ici : une fonction dont le nom commence par test_. C'est une fonction async, parce que await n'est autorisé qu'à l'intérieur d'une telle fonction. Et pour être sûr que le test vérifie vraiment quelque chose, le prix attendu est volontairement faux :
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.11sLisez l'échec attentivement. Il ne dit rien de 15.0 ni de 999.0. L'assertion n'a jamais tourné. Appeler une fonction async def ne lance pas son corps — elle ne crée qu'une coroutine, et quelque chose doit piloter une boucle d'événements pour l'exécuter. Pytest pur ne le fait pas. (Les anciennes versions de pytest sautaient un tel test avec un avertissement, donc il paraissait vert sans rien tester. Pytest 9 le fait échouer, ce qui est bien mieux.)
C'est la première moitié de ce chapitre : faire exécuter du code async par pytest. La seconde moitié est l'idée plus profonde derrière le correctif — pytest est lui-même un ensemble de hooks, et un plugin n'est que du code qui en remplit certains. Une fois que vous voyez cela, vous pouvez écrire les vôtres : un drapeau --runslow, un marqueur, une ligne dans l'en-tête du rapport, et une suite de tests pour le plugin lui-même.
À la fin de ce chapitre, vous saurez
- Expliquer pourquoi un
async def test_...nu ne s'exécute pas, et reconnaître l'échec - Lancer les tests async avec
pytest-asyncio, en utilisant@pytest.mark.asyncioouasyncio_mode = "auto" - Écrire des fixtures async, y compris avec nettoyage après
yield - Ajouter une option de ligne de commande avec
pytest_addoptionet la lire avecrequest.config.getoption - Ignorer les tests lents sauf si
--runslowest donné, avecpytest_collection_modifyitems - Enregistrer un marqueur dans
pytest_configureet ajouter une ligne avecpytest_report_header - Dire où pytest trouve les hooks :
conftest.py,-p, et les points d'entréepytest11 - Tester un plugin avec la fixture
pytester
Prérequis : couverture et CI.
Avant d'écrire le test
Ce chapitre teste deux types de choses différentes — une fonction async et un plugin pytest — et les deux demandent de la réflexion avant la première ligne de test.
Ce qui est promis. Écrivez le contrat en mots simples d'abord, parce que les tests sont ce contrat, traduit.
fetch_price(item)est une coroutine. Avec un article connu, elle renvoie le prix de cet article. Avec un article inconnu, elle lèveKeyError. Beaucoup d'appels attendus ensemble doivent chevaucher, pas faire la queue.- Le plugin de tests lents promet : sans
--runslow, chaque test marquéslowest ignoré, avec une raison ; avec--runslow, tout s'exécute ; l'en-tête indique lesquels ; et le marqueurslowest enregistré, donc il ne lève aucun avertissement.
Ce qui doit être en place. Les tests async ont besoin d'un runner, que pytest n'a pas : installez pytest-asyncio et choisissez le mode strict ou auto avant d'écrire les tests, car les décorateurs que vous écrirez en dépendent. Le code testé doit être importable — prices.py à la racine du projet, et plus tard le module plugin aussi (pythonpath = ["."]). Tester le plugin demande la fixture pytester activée. Et rien ne doit avoir besoin d'un vrai réseau : asyncio.sleep tient la place.
Le plan. Une ligne par comportement, chacune avec son entrée et ce que vous attendez de voir :
| Cas | Entrée | Attendu | | --- | --- | --- | | article connu | await fetch_price("pen") | 15.0 | | article inconnu | await fetch_price("stapler") | KeyError | | await oublié | fetch_price("pen") dans un test ordinaire | un objet coroutine, pas un prix | | plusieurs à la fois | 400 appels via asyncio.gather | les 400 reviennent, en gros le temps d'un seul | | exécution par défaut | pytest avec un test slow | 1 passed, 1 skipped | | drapeau donné | pytest --runslow | 2 passed | | en-tête | pytest | une ligne slow tests: skipped (use --runslow) | | marqueur | pytest --markers | slow listé, sans PytestUnknownMarkWarning |
Ce qu'il ne faut pas tester. Pas asyncio.sleep, ni la boucle d'événements — Python les teste. Pas le mécanisme de skip de pytest : vous testez que votre hook attache le skip, en comptant les résultats, pas comment pytest imprime un s. Pas les timings exacts non plus : « 400 appels finissent en moins d'une seconde » est une affirmation stable sur n'importe quelle machine, tandis que « en 0,013 seconde » ne l'est pas.
Le reste du chapitre traite ce tableau, ligne par ligne.
Exécuter les tests async avec pytest-asyncio
Le message d'échec liste plusieurs plugins. Pour du code basé sur asyncio, le choix habituel est pytest-asyncio :
pip install pytest-asyncioL'installer ne suffit pas. Relancez le même test et la sortie est mot pour mot la même : async def functions are not natively supported. Par défaut pytest-asyncio travaille en mode strict — il ne touche qu'aux tests que vous lui avez explicitement marqués. Vous en marquez un avec un marqueur :
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.10sMaintenant, il échoue pour la bonne raison. assert 15.0 == 999.0 — la coroutine s'est exécutée, await a produit un vrai nombre, et l'assertion l'a comparé. Voir un test échouer avec la mauvaise valeur est la preuve qu'il s'exécute vraiment. Corrigez la valeur, et ajoutez un test pour le cas d'erreur pendant que vous y êtes :
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 fonctionne dans un test async exactement comme ailleurs ; le await va simplement à l'intérieur du bloc with.
Il y a une autre mauvaise façon qui vaut la peine d'être vue, parce que c'est la plus courante : éviter toute la question en écrivant un test def ordinaire et en appelant la fonction async sans 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 awaitedLe rapport dit exactement ce qui s'est passé : fetch_price('pen') a renvoyé un objet coroutine, et une coroutine n'est pas égale à 15.0. La dernière ligne est Python qui ajoute que la coroutine a été jetée sans jamais s'exécuter. Celui-ci au moins échoue de façon évidente. La variante dangereuse est assert fetch_price("pen") sans comparaison — un objet coroutine est truthy, donc ce test rapporte 1 passed, 1 warning sans rien vérifier. Tout test de code async doit lui-même être async def et doit await.
asyncio_mode = "auto" et fixtures async
Écrire @pytest.mark.asyncio au-dessus de chaque test devient vite fastidieux. Dans un projet où async est la norme, passez en mode auto dans pyproject.toml :
[tool.pytest.ini_options]
asyncio_mode = "auto"En mode auto, pytest-asyncio prend en charge chaque test async def et chaque fixture async def, sans marqueur. L'en-tête d'un pytest complet vous dit quel mode est en vigueur :
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionLes fixtures peuvent être async aussi. Ici un panier est créé, remis au test, et fermé après — avec un await des deux côtés du 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.11sLe yield fonctionne comme dans le chapitre sur les fixtures : tout ce qui est avant est initialisation, tout ce qui est après est nettoyage, et le nettoyage s'exécute même quand le test échoue.
En mode strict, la même fixture a besoin de son propre décorateur, @pytest_asyncio.fixture, depuis import pytest_asyncio. Avec un @pytest.fixture ordinaire là, pytest ne sait pas comment la lancer et s'arrête à l'initialisation :
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.09sLe choix est donc simple. Mode strict : marquez les tests async avec @pytest.mark.asyncio et les fixtures async avec @pytest_asyncio.fixture. Mode auto : async def simple partout. Choisissez-en un par projet et écrivez-le dans la config pour que personne n'ait à deviner.
Comment pytest-asyncio a-t-il fait ça ? Il n'a pas changé pytest. Il a implémenté quelques hooks de pytest — des fonctions aux noms fixes que pytest appelle à des moments fixes, comme « un test va être lancé ». C'est tout ce qu'est un plugin. Le reste de ce chapitre vous guide pour écrire votre propre plugin.Hooks dans conftest.py
Un hook est une fonction nommée pytest_<something> que pytest appelle à un moment particulier d'une exécution. Vous ne l'enregistrez pas ; vous la définissez dans un endroit que pytest regarde, et le nom fait le reste. Le premier tel endroit est le fichier conftest.py que vous utilisez déjà pour les fixtures partagées.
Une option de ligne de commande : pytest_addoption
Supposons que les mêmes tests doivent s'exécuter contre votre portable et contre un serveur de staging. L'adresse appartient à la ligne de commande, pas au 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, puis 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 s'exécute une fois, avant que la ligne de commande ne soit analysée, et parser.addoption prend les mêmes arguments que argparse de Python. Ensuite request.config est l'objet de configuration de l'exécution, et getoption relit la valeur. L'option apparaît aussi dans pytest --help :
--shop-url=SHOP_URL where the shop under test runsEncapsuler l'option dans une fixture est le motif habituel : les tests demandent shop_url et ne touchent jamais la configuration directement.
Ignorer les tests lents : pytest_collection_modifyitems
Un besoin courant : certains tests prennent des secondes, et vous ne les voulez pas à chaque sauvegarde — seulement avant un push, ou en CI. Marquez-les slow, et ignorez-les sauf si un drapeau --runslow est donné.
La tentation est de mettre la vérification dans chaque test lent : lire l'option en haut, appeler pytest.skip() si elle manque. Ça fonctionne, mais c'est la mauvaise approche. La règle se trouve alors dans chaque test lent au lieu d'un seul endroit ; le jour où quelqu'un écrit un nouveau test lent et oublie ces deux lignes, il tourne à chaque sauvegarde et personne ne remarque pourquoi la suite a ralenti. Et le corps du test doit commencer à s'exécuter avant de pouvoir décider de ne pas s'exécuter, donc ses fixtures sont déjà initialisées — peut-être une base de données, peut-être un serveur.
Le bon endroit est avant que tout test ne commence. Après que pytest a collecté tous les tests, et avant qu'aucun ne s'exécute, il appelle pytest_collection_modifyitems(config, items). items est la liste des tests collectés, et le hook peut la changer — une règle, en un seul endroit, appliquée à chaque test qui porte le marqueur.
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, puis pytest -q --runslow :
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sSans le drapeau, le test lent est un s et l'exécution prend un dixième de seconde. Avec, les deux s'exécutent et le second coûte sa seconde entière. action="store_true" fait de --runslow un interrupteur : absent signifie False, présent signifie True. Dans un hook il n'y a pas de request, donc le hook lit la valeur directement depuis config.
item.keywords tient les noms des marqueurs du test (entre autres), donc "slow" in item.keywords demande « ce test porte-t-il @pytest.mark.slow ? ». item.add_marker(skip_slow) attache un skip à ce test, exactement comme si vous aviez écrit @pytest.mark.skip(reason="needs --runslow") au-dessus vous-même.
Enregistrer le marqueur et ajouter une ligne d'en-tête
Deux hooks plus petits dans ce fichier méritent un mot.
pytest_configure(config) s'exécute une fois, après l'analyse de la ligne de commande. config.addinivalue_line("markers", ...) enregistre le marqueur slow — pareil qu'une ligne markers = [...] dans pyproject.toml, mais porté par le plugin lui-même. Sans, chaque @pytest.mark.slow produit PytestUnknownMarkWarning, et sous --strict-markers ça devient une erreur. Maintenant pytest --markers le liste en premier :
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) renvoie une chaîne (ou une liste de chaînes) que pytest imprime au sommet d'une exécution. C'est le bon endroit pour dire ce qu'un lecteur d'un log CI a besoin de savoir. 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 ignoré avec une raison claire est honnête : le résumé dit ce qui n'a pas tourné, et pourquoi.
Comment pytest trouve les hooks
Chacun de ces hooks réside dans un module. Pytest collecte de tels modules — plugins — depuis plusieurs endroits :
- Ses propres plugins intégrés. La plupart de pytest est écrit sous forme de plugins :
-k, marqueurs,tmp_path, le rapport terminal. - Paquets installés qui déclarent un point d'entrée
pytest11. C'est comme ça quepytest-asyncio,pytest-covet les autres se chargent dès qu'ils sont installés. La ligneplugins:de l'en-tête les liste. - Modules nommés avec
-psur la ligne de commande, ou dansaddopts. - Fichiers
conftest.py, à la racine de vos tests et dans n'importe quel sous-répertoire (leconftest.pyd'un sous-répertoire ne s'applique qu'aux tests en dessous). Quelques hooks précoces, commepytest_addoption, ne sont pris en compte que dans unconftest.pyà la racine.
Donc le conftest.py ci-dessus peut être extrait tel quel. Renommez-le en pytest_slowtests.py, et c'est un plugin. Chargez-le avec -p et le nom du module :
pytest -p pytest_slowtestsLe module doit être importable. S'il est à la racine du projet, dites-le à pytest avec pythonpath, et pour éviter de taper -p à chaque fois, mettez-le dans addopts :
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"Pour le partager entre projets, transformez-le en paquet et déclarez le point d'entrée. Le groupe doit s'appeler pytest11 ; le nom à gauche est le nom du plugin, et la valeur à droite est le 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"Installez-le (pip install ./pytest-slowtests) dans un environnement frais, et dans un projet sans aucun 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 l'a trouvé par le point d'entrée. L'inverse marche aussi : -p no:slowtests désactive un plugin installé pour une exécution, ce qui est utile quand vous soupçonnez un plugin de causer des ennuis.
Tester un plugin avec pytester
Un plugin change la façon dont pytest se comporte, donc ses tests doivent exécuter pytest et regarder ce qui s'est passé. Pytest fournit une fixture pour exactement ça, pytester. Elle est désactivée par défaut ; activez-la dans le module de test (ou dans le conftest.py racine) :
pytest_plugins = ["pytester"]pytester donne à chaque test un répertoire temporaire vide, un petit projet frais. pytester.makepyfile(...) écrit un fichier de test dedans, pytester.runpytest(...) lance pytest là-dedans avec les arguments que vous donnez, et le résultat a assert_outcomes(passed=..., skipped=..., failed=...) pour vérifier les comptes, et result.stdout.fnmatch_lines([...]) pour vérifier des lignes de sortie (* peut servir de joker). La version complète est dans l'exemple complet ci-dessous.
Un exemple complet
Un petit projet contenant tout ce chapitre : code async, tests async en mode auto, le plugin de tests lents, et tests pour le plugin.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py est celui du début du chapitre, et pytest_slowtests.py est le conftest.py de la section --runslow, renommé. 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 (en-tête raccourci) :
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 =========================Et pytest -q --runslow :
...... [100%]
6 passed in 0.40sTrois choses méritent d'être remarquées.
Premièrement, le plugin fonctionne sur les tests async aussi. test_every_price_at_once est un async def avec un marqueur slow, et le skip s'applique à lui comme à n'importe quel autre test. Les hooks voient les items collectés ; que la fonction derrière l'un soit async concerne pytest-asyncio, pas eux.
Deuxièmement, asyncio.gather a lancé 400 appels de fetch_price, chacun dormant 0,01 seconde, et l'exécution entière a pris bien moins d'une seconde — pas quatre. Ils ont attendu simultanément. C'est la raison d'être du code async, et le test prouve qu'il se comporte ainsi.
Troisièmement, la ligne makeini dans run. L'exécution interne dans pytester est un projet séparé avec sa propre configuration, et il charge tous les plugins installés — y compris pytest-asyncio, qui avertit quand asyncio_default_fixture_loop_scope n'est pas défini. Donner à ce projet interne ce seul réglage garde le rapport externe propre. La leçon générale : une exécution pytester voit vos plugins installés, mais pas votre pyproject.toml.
Quand le plugin casse, ces tests le disent précisément. Ici la vérification du skip a été mal orthographiée comme "slw", donc rien n'a été ignoré :
> 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 diffEn dessous, sous Captured stdout call, pytest imprime toute la sortie de l'exécution interne, donc vous voyez exactement ce qu'elle a fait.
En cas de problème
async def functions are not natively supported. Le test est un async def, et rien ne le lance. Soit pytest-asyncio n'est pas installé, soit il est installé en mode strict et le test manque @pytest.mark.asyncio. Ajoutez le marqueur, ou mettez asyncio_mode = "auto".
requested an async fixture 'cart', with no plugin or hook that handled it Une fixture async def est décorée avec un @pytest.fixture ordinaire en mode strict. Utilisez @pytest_asyncio.fixture, ou passez en mode auto.
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, suivi de RuntimeWarning: coroutine 'fetch_price' was never awaited Un test def ordinaire a appelé une fonction async sans await, et a comparé l'objet coroutine lui-même. Faites le test async def et écrivez await fetch_price("pen").
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Le marqueur est utilisé mais jamais enregistré. Enregistrez-le dans pytest_configure avec config.addinivalue_line("markers", ...), ou dans le réglage markers.
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> Une fonction commence par pytest_ mais n'est pas un hook que pytest connaît — ici, un s en trop. Pytest vérifie chaque nom pytest_ dans un plugin, ce qui attrape exactement la faute de frappe. Vérifiez l'orthographe contre la référence des hooks.
ValueError: no option named '--runslow' config.getoption a demandé une option qu'aucun pytest_addoption n'a ajoutée. Soit le nom diffère, soit le hook pytest_addoption réside dans un conftest.py que pytest n'a pas chargé assez tôt — gardez-le à la racine de vos tests.
pytest: error: unrecognized arguments: --run-slow L'option sur la ligne de commande ne correspond pas à celle enregistrée. pytest --help liste toutes les options que vos plugins ont ajoutées, sous « options personnalisées ».
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p prend un nom de module, et ce module n'a pas pu être importé. Ajoutez pythonpath = ["."] à la config, ou installez le plugin comme paquet.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
fetch_price("pen") renvoie vraiment 15.0. Que rapporte 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
En mode strict ce test ERROR à l'initialisation. Quel est le correctif ?
# 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)]- ARemplacer `yield` par `return`
- BDécorer la fixture avec `@pytest_asyncio.fixture` (ou mettre `asyncio_mode = "auto"`)
- CRetirer `@pytest.mark.asyncio` du test
- DRendre `test_add` un test `def` ordinaire
Les deux fichiers sont dans un même dossier. Que montre pytest -q, sans drapeaux ?
# 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
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez un plugin, pytest_budget.py, qui donne à chaque test un budget de temps.
- Ajoutez une option
--budgetavec une valeur par défaut de0.5(secondes) ; utiliseztype=floatdansparser.addoption. - Fournissez une fixture
budgetqui renvoie la valeur viarequest.config.getoption("--budget"). - Enregistrez un marqueur
networkdanspytest_configure, et imprimezbudget: 0.5s(ou ce qui a été donné) avecpytest_report_header. - Dans
pytest_collection_modifyitems, ignorez chaque test marquénetworkquand une seconde option,--offline, est donnée. - Chargez-le avec
-p pytest_budget, et écrivez un test async en mode auto quiawait asyncio.sleep(...)pour moins quebudgetet affirme qu'il a fini dans les temps.
Puis testez le plugin avec pytester : un test pour chaque option, en utilisant assert_outcomes pour les comptes et fnmatch_lines pour l'en-tête. Finalement, cassez le plugin exprès — renommez pytest_report_header en pytest_report_headers — et lisez ce que pytest dit. Une fois vue, cette erreur devient facile à reconnaître définitivement.
Essayez vous-même avant de lire la suite.
Solution
Le projet a la même forme que l'exemple complet :
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 (en-tête raccourci) :
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.57sEt avec le hook renommé en pytest_report_headers, rien ne s'exécute du tout :
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.00sPourquoi chaque décision :
type=float. Sans,--budget 2arrive comme la chaîne"2", etbudget / 5lèveTypeError.test_budget_option_is_a_floatexiste pour valider ce point : le test interne affirmebudget == 2.0, et la chaîne"2"ne lui serait pas égale.- La fixture réside dans le plugin. Un module plugin peut définir des fixtures comme un
conftest.py, donc quiconque charge-p pytest_budgetobtientbudgetaussi. Les tests demandentbudget; aucun ne sait qu'il y a une option derrière. - Le skip est décidé à la collecte, pas dans le test.
test_price_service_answersne contient aucunif. Un hook applique la règle, et un nouveau testnetworkest couvert dès qu'il est écrit. - Le test de timing revendique une marge, pas un chiffre. Il attend un cinquième du budget et affirme avoir fini dans le budget entier. Ça tient sur une machine CI lente ; « a pris 0,1 seconde » ne tiendrait pas.
- Un seul helper
run. Chaque testpytesterconstruit le même projet interne, donc l'initialisation — y compris la lignemakeiniqui gardepytest-asynciosilencieux dans l'exécution interne — s'écrit une fois. Le premier jet de cette solution oubliait le helper danstest_marker_is_registered, et l'exécution externe a rapporté1 warningde cette unique exécution interne. - Comptes pour le comportement, lignes pour la sortie.
assert_outcomesvérifie ce que le plugin fait aux tests ;fnmatch_linesvérifie ce qu'il imprime. Le*dans"@pytest.mark.network: *"empêche le test de casser si la description du marqueur est reformulée. - Le hook cassé. Pytest vérifie chaque fonction dont le nom commence par
pytest_contre les hooks qu'il connaît, et refuse de démarrer si l'un ne correspond pas. Une faute de frappe dans un nom de hook ne peut pas passer inaperçue — et les testspytesterl'auraient détecté de toute façon, puisquetest_header_shows_budgetcherche une ligne qui ne serait jamais imprimée.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz