Configuration et avertissements — les règles dans un seul fichier
Arrêtez de taper les mêmes drapeaux à chaque exécution : mettez les règles du projet dans pyproject.toml ou pytest.ini — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — et testez et contrôlez les avertissements avec pytest.warns, deprecated_call et filterwarnings.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Le projet shop a désormais la forme que partagent la plupart des vrais projets : le code dans src/, les tests dans tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyVous avez appris à l'exécuter correctement, et « correctement » est devenu une longue ligne :
PYTHONPATH=src pytest -ra --strict-markers testsUn collègue clone le dépôt et tape ce que n'importe qui taperait :
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================Le code n'a rien de faux. Ce qui cloche, c'est que la façon d'exécuter les tests vit dans votre tête et dans l'historique de votre shell, plutôt que dans le projet. L'intégration continue exécutera une troisième variante, et trois personnes verront trois résultats pour un seul commit.
Ces drapeaux ne sont pas une question de préférence ; ce sont les règles du projet. Ce chapitre les déplace dans un fichier que pytest lit à chaque fois — puis utilise ce même fichier pour régler un second problème, plus silencieux : les avertissements qui défilent sans jamais être lus.
À la fin de ce chapitre, vous saurez
- Placer les réglages de pytest dans
pyproject.toml(oupytest.ini) et confirmer dans l'en-tête quel fichier a été utilisé - Choisir
testpaths,addopts,pythonpath,minversion,markersetxfail_strictde façon réfléchie, et dire pourquoi - Activer la journalisation en direct avec
log_cliquand vous avez besoin de surveiller une exécution - Vérifier qu'un code émet un avertissement avec
pytest.warnsetpytest.deprecated_call, à l'aide dematchet de la liste enregistrée - Contrôler les avertissements avec
filterwarningsdans la configuration, sous forme de marqueur et avec-W— et en faire des erreurs dans l'intégration continue
Prérequis : marqueurs, skip et xfail.
Avant d'écrire le test
Ce chapitre écrit moins de tests que d'habitude et plus de règles sur la façon dont les tests s'exécutent. La réflexion qui vient en premier est la même : décider ce qui est promis, puis planifier les cas qui vérifient la promesse.
Ce qui est promis. Deux types de promesses sont en jeu.
- La façon dont la suite s'exécute. Quiconque tape simplement
pytestobtient les mêmes tests, le même chemin d'import et la même rigueur que l'intégration continue. Un marqueur mal orthographié arrête l'exécution. Unxfailqui se met à réussir est signalé, pas caché. - Ce que le code dit en plus de sa valeur de retour.
apply_discount(total, percent)renvoie un nombre, et pourpercent > 50il émet aussi unUserWarningdisant que la remise « looks unusually large ».total_with_vat()fonctionne toujours mais émet unDeprecationWarningqui pointe versline_total(). Un avertissement est une sortie ; lorsqu'il fait partie du contrat, il est testé comme une sortie.
Ce qui doit être en place. Un environnement virtuel avec pytest 9 (pytest --version), le code importable depuis les tests (c'est pythonpath plus bas), et un seul fichier de configuration à la racine du projet. Aucun plugin supplémentaire.
Le plan pour le comportement des avertissements :
| cas | entrée | attendu | | --- | --- | --- | | chemin nominal | apply_discount(200.0, 10) | 180.0, aucun avertissement | | frontière, encore silencieux | apply_discount(200.0, 50) | 100.0, aucun avertissement | | juste au-delà de la frontière | apply_discount(200.0, 60) | 80.0 et un UserWarning mentionnant « unusually large » | | nom dépréqué | total_with_vat(100.0, 1) | 115.0 et un DeprecationWarning mentionnant line_total | | la dépréciation d'une bibliothèque | n'importe quoi qui la déclenche | ne fait pas échouer notre suite |
Pour la configuration elle-même, le « test » est une exécution : pytest simple depuis la racine et depuis l'intérieur de tests/ doivent dans les deux cas collecter uniquement tests/ et réussir, et un marqueur mal orthographié doit arrêter l'exécution.
Ce qu'il ne faut pas tester. Ni le fait que le module warnings de Python fonctionne, ni le fait que pytest lit sa configuration — c'est du code testé par quelqu'un d'autre. Ni le texte complet d'un avertissement : faites correspondre la phrase sur laquelle un lecteur agit (« unusually large »), pour que reformuler le reste ne casse rien. Ni les dépréciations d'une bibliothèque tierce — elles sont filtrées, ce qui est une décision de configuration, pas un test.
La ligne de frontière est celle qu'on oublie, car un avertissement manquant ne fait aucun bruit. À la fin du chapitre, une seule ligne de configuration la transforme en une vérification que vous obtenez gratuitement.
Un fichier unique, lu avant tout le reste
pytest lit ses réglages avant de collecter le moindre test. L'emplacement habituel est le pyproject.toml que le projet a déjà, dans une table appelée [tool.pytest.ini_options] :
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath liste des répertoires, relatifs à la racine du projet, que pytest ajoute à sys.path avant d'importer les tests. Le code testé est banal :
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Lisez l'en-tête. configfile: pyproject.toml, c'est pytest qui vous dit quel fichier il a utilisé. Chaque fois que des réglages semblent ignorés, vérifiez d'abord cette ligne.
Pourquoi ne pas réparer l'import dans le fichier de test lui-même ? C'est la mauvaise façon évidente, et elle fonctionne — parfois :
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" là est relatif au répertoire courant, donc le test passe ou casse selon l'endroit où vous vous trouvez. pythonpath dans la configuration est relatif à la racine du projet, qui ne bouge pas. La mise en place de l'import appartient au projet : elle va une fois pour toutes dans le fichier du projet, pas en haut de chaque module de test.
Quel fichier l'emporte, et où se trouve la racine
pytest accepte plusieurs fichiers. Dans chaque répertoire, il vérifie, dans cet ordre : pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. Il part des chemins de la ligne de commande (ou du répertoire courant) et remonte vers le haut ; le premier fichier qui contient réellement une section pytest l'emporte. Un pyproject.toml sans table pytest ne compte pas.
Ce répertoire devient la rootdir — la ligne rootdir: de l'en-tête — et les chemins relatifs dans la configuration le sont par rapport à lui. Même depuis l'intérieur de tests/, on trouve donc toujours la racine :
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 itemsLes mêmes réglages dans pytest.ini utilisent la syntaxe INI, une section [pytest], et ni guillemets ni crochets :
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Choisissez un seul fichier. Si les deux existent, pytest.ini l'emporte complètement — rien n'est fusionné — et l'en-tête le dit. Ici, un pytest.ini de deux lignes (minversion = 9.0) a été ajouté à côté du pyproject.toml :
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…et le ModuleNotFoundError est de retour, parce que pythonpath vivait dans le fichier qui est désormais ignoré.
testpaths — où chercher quand vous ne dites rien
Quelqu'un a laissé un fichier de brouillon, notes/test_scratch.py, qui essaie des arrondis. pytest simple collecte tout ce qui se trouve sous la racine, il s'exécute donc aussi :
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpaths dit où se trouve la vraie suite :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sDans l'en-tête complet, vous verriez aussi testpaths: tests. C'est une valeur par défaut, pas une barrière : elle ne s'applique que lorsque vous lancez depuis la racine sans nommer de chemins, donc pytest notes exécute quand même le fichier de brouillon. Sur un gros dépôt, cela accélère aussi la collection, car pytest cesse de parcourir docs/, build/ ou un dossier d'environnement virtuel.
addopts et markers — des règles pour tout le monde
addopts est du texte que pytest place devant ce que vous tapez. Les deux règles du projet du début vont ici :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra imprime une courte ligne de résumé pour chaque test qui n'a pas simplement réussi — ignoré, xfailed, xpassed, échoué — afin qu'un abandon ne passe jamais inaperçu. --strict-markers transforme un marqueur non enregistré en erreur. Un nouveau fichier, tests/test_discounts.py, contient un test marqué @pytest.mark.slow (et un test ignoré hors Windows), et le marqueur n'est pas encore enregistré :
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sC'est tout l'intérêt du drapeau. Sans lui, une faute de frappe comme @pytest.mark.slwo ne mérite qu'un PytestUnknownMarkWarning dans le résumé, l'exécution reste verte, et -m "not slow" continue d'exécuter le test que vous vouliez exclure. Enregistrez le marqueur, une ligne chacun, avec une description que pytest --markers affichera :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'La ligne SKIPPED, c'est -ra au travail : un abandon s'explique désormais à chaque exécution.
Ce qui n'a pas sa place dans addopts. La mauvaise façon est addopts = "-v -x --pdb". Ce sont vos habitudes du jour pour le débogage ; dans le fichier partagé, elles sont imposées à tout le monde, intégration continue comprise — où --pdb attend à jamais un clavier auquel personne ne touche. Ne mettez dans addopts que les règles du projet. Ce que vous tapez s'ajoute après, donc pytest -x continue de fonctionner pour vous seul, et -o annule n'importe quel réglage pour une exécution :
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sPas de ligne d'abandon cette fois, car -ra a été écarté pour cette exécution.
minversion et xfail_strict
minversion arrête l'exécution sur-le-champ lorsque le pytest de quelqu'un est trop vieux pour le fichier. Demander une version qui n'existe pas affiche le message :
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'Une erreur claire au démarrage vaut mieux qu'un pytest plus ancien qui comprend à moitié les réglages.
xfail_strict traite le point laissé en suspens au chapitre précédent : un xfail qui se met à réussir. Ici, un bug était marqué comme censé échouer, et depuis, quelqu'un l'a corrigé :
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sUne exécution verte, un X que personne ne lit, et un marqueur qui ment désormais. Avec xfail_strict = true dans la configuration :
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sLa bonne nouvelle arrive désormais sous la forme d'un échec qui vous dit de retirer le marqueur — c'est pourquoi il vaut le coup de le définir pour tout le projet plutôt que test par test. pytest 9.1 accepte aussi le nom plus récent strict_xfail, ainsi qu'un simple strict = true qui active d'un coup toutes les options de rigueur (marqueurs et xfail compris) ; xfail_strict continue de fonctionner.
Journalisation en direct : log_cli
Par défaut, pytest capture les enregistrements de journal et ne les montre que pour les tests en échec. Pour voir une exécution qui réussit raconter son histoire, activez la journalisation en direct :
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"Les enregistrements INFO et supérieurs de chaque test sont alors imprimés sous un titre live log call pendant son exécution ; l'exemple complet ci-dessous le montre. La journalisation en direct est bruyante, donc beaucoup de projets ne gardent que log_cli_level dans le fichier et activent la sortie au besoin avec pytest -o log_cli=true.
La table native de pytest 9 : [tool.pytest]
[tool.pytest.ini_options] a un nom étrange pour une raison : ce sont des réglages INI posés dans un fichier TOML, donc chaque valeur est convertie en texte. pytest 9 ajoute une table native, [tool.pytest], où les valeurs conservent leurs types TOML — de vraies listes, de vrais booléens :
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddopts est maintenant une liste. Deux règles : utilisez une table ou l'autre, jamais les deux dans un seul fichier (pytest refuse de démarrer), et souvenez-vous que les versions de pytest antérieures à 9 ne lisent pas [tool.pytest] du tout. Ce chapitre garde [tool.pytest.ini_options] car cela fonctionne avec toutes les versions de pytest que vous risquez de rencontrer ; sur un projet qui ne fait tourner que pytest 9, la table native est la forme recommandée. pytest 9 lit aussi un pytest.toml autonome avec une table [pytest] dans le même style natif.
Le cache, en bref
Après une exécution, il y a un dossier .pytest_cache/ à la racine. Il se souvient des tests qui ont échoué la dernière fois, ce qui alimente --lf (last failed) et --ff (failed first). Deux drapeaux valent la peine d'être connus. pytest --cache-clear le vide avant l'exécution — utile lorsque --lf choisit sans cesse des tests qui n'existent plus. pytest -p no:cacheprovider désactive complètement le plugin de cache, donc rien n'est écrit, ce qui convient à une copie en lecture seule. Le coût, c'est que les options du plugin disparaissent avec lui :
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvLes avertissements : la section en bas de l'exécution
Un avertissement est un message qui indique que quelque chose n'est pas encore faux. Python l'imprime et continue :
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2 fait pointer la dépréciation vers la ligne de l'appelant, la ligne que quelqu'un doit changer. pytest attrape les avertissements levés pendant les tests et les rassemble dans une section à la fin :
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sLe test a réussi, et la dernière ligne dit 1 passed, 1 warning. Dans une grande suite, cette section grossit jusqu'à des dizaines d'entrées, tout le monde apprend à le faire défiler sans le lire, et un jour la fonction dépréquée est supprimée et la suite « qui passe » casse. On peut faire deux choses avec un avertissement : s'y attendre, lorsqu'il fait partie du comportement, ou le refuser, lorsqu'il n'en fait pas partie.
pytest.warns — s'attendre à un avertissement
Lorsqu'un avertissement fait partie du contrat — la ligne « juste au-delà de la frontière » du plan — testez-le comme vous testez une exception avec pytest.raises :
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sLe bloc réussit lorsqu'au moins un UserWarning (ou sous-classe) y est émis et que son message correspond à match, une expression régulière recherchée dans le texte, comme avec pytest.raises. Les deux moitiés échouent bruyamment. Ici, le code est silencieux à 50, et le second test cherche les mauvais mots :
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sLes deux rapports listent les avertissements qui ont été émis, ce qui suffit en général pour corriger le test. Un avertissement qui ne correspond pas n'est pas avalé non plus ; il part dans le résumé des avertissements.
Pourquoi toujours donner match. pytest.warns(UserWarning) tout seul est la forme faible : n'importe quel UserWarning venu de n'importe où dans le bloc le satisfait — y compris celui d'une bibliothèque que vous appelez au hasard, pour une raison sans rapport. Le test pourrait continuer à passer après que l'avertissement qui vous intéresse a disparu. Une phrase qui correspond ancre le test sur cet avertissement.
Pour vérifier combien d'avertissements se sont déclenchés, ou ce qu'ils disaient, gardez l'enregistrement avec as :
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecord liste chaque avertissement attrapé dans le bloc — deux, car l'appel à 10 % est silencieux. Chaque entrée a .message, .category, .filename et .lineno.
pytest.deprecated_call — une promesse à vos utilisateurs
Déprécier une fonction, c'est une promesse : « cela fonctionne toujours, et on vous l'a dit ». pytest.deprecated_call() vérifie les deux moitiés :
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sC'est pytest.warns avec les catégories déjà renseignées — DeprecationWarning, PendingDeprecationWarning et FutureWarning — et il accepte aussi match. L'entrée dans le résumé des avertissements a disparu : l'avertissement était attendu, il a donc été consommé.
filterwarnings — refuser les avertissements
Les avertissements attendus sont désormais des tests. Le reste est du bruit ou des alertes prématurées, et l'habitude d'intégration continue qui vaut la peine d'être adoptée est la suivante : chaque avertissement inattendu est une erreur. Une ligne :
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Voici une suite avec un test propre, un qui appelle encore le nom dépréqué, et un qui appelle currency.rate() d'une bibliothèque que vous ne pouvez pas modifier, laquelle émet son propre DeprecationWarning :
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sChaque avertissement est désormais levé comme une exception, avec une trace qui pointe vers la ligne qui l'a causé. Chaque échec reçoit une décision plutôt qu'un silence généralisé.
La mauvaise façon est "ignore::DeprecationWarning" : une ligne, tout au vert, et votre propre dépréciation cachée avec celle de la bibliothèque. La bonne façon est de restreindre chaque exception. Un filtre a la forme action:message:category:module — la même que l'option -W de Python lui-même — où message correspond au début du texte de l'avertissement et module est le module auquel l'avertissement est attribué :
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]L'ordre compte : les lignes suivantes priment, donc error va en premier et les exceptions ensuite. L'avertissement de la bibliothèque n'est désormais ignoré que lorsqu'il vient de currency. Pour un seul test, le même filtre peut être attaché sous forme de marqueur — ici sur test_old_name, le reste du fichier inchangé :
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sCe marqueur autorise seulement l'avertissement. Si vous voulez dire « ceci doit émettre un avertissement », pytest.deprecated_call() est le choix le plus fort, car il échoue aussi quand l'avertissement disparaît. Utilisez le marqueur pour les avertissements que vous tolérez, et warns pour ceux que vous promettez.
Les mêmes filtres peuvent être donnés pour une seule exécution avec -W, pratique pour essayer une politique avant de la consigner. Sur le test_old_api.py précédent, sans aucune configuration :
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sLorsque les sources sont en désaccord, la plus spécifique l'emporte : un marqueur sur le test bat -W, et -W bat le fichier de configuration. Sur la suite à trois tests, -W annule le ignore de la configuration pour currency, tandis que le test marqué garde son exemption :
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sEncore un avantage que error vous offre. Revenez à la ligne de frontière du plan, « aucun avertissement à 50 ». Avec error dans la configuration, un simple test de apply_discount(200.0, 50) le vérifie déjà : si cet appel avertissait un jour, l'avertissement serait une exception et le test échouerait. Les cas silencieux n'ont besoin d'aucun code spécial.
Un exemple complet
Le projet shop avec chaque règle au même endroit. pyproject.toml :
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py :
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — le plan du début du chapitre, ligne par ligne :
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================Trois choses méritent d'être remarquées.
Premièrement, rien dans l'une ou l'autre commande n'est une règle du projet. Les règles — où sont les tests, ce qui est importable, marqueurs stricts, xfail strict, avertissements en erreurs — sont toutes dans le fichier, donc le collègue qui tape simplement pytest obtient exactement la première exécution, et l'intégration continue aussi.
Deuxièmement, il n'y a pas de résumé des avertissements. Les deux avertissements émis par le code sont attendus, donc warns et deprecated_call les ont consommés ; tout autre avertissement aurait fait échouer un test. Une exécution propre est désormais réellement propre.
Troisièmement, test_fifty_percent_is_quiet ne contient aucun code d'avertissement, et pourtant il garde la frontière : sous error, un avertissement à 50 % le ferait échouer.
En cas de problème
ModuleNotFoundError: No module named 'shop' pendant la collection Le code n'est pas importable depuis les tests. Ajoutez pythonpath = ["src"] à la configuration, et vérifiez la ligne configfile: de l'en-tête pour être sûr que c'est ce fichier qui est lu.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Deux fichiers de configuration, et celui que vous avez modifié a perdu. Rien n'est fusionné. Supprimez-en un.
'slow' not found in markers configuration option --strict-markers (ou strict = true) est activé, et le marqueur est non enregistré ou mal orthographié. Enregistrez-le sous markers, ou corrigez l'orthographe.
PytestConfigWarning: Unknown config option: testpath Un réglage mal orthographié — testpath au lieu de testpaths. pytest ne fait qu'avertir, le réglage ne fait donc rien du tout en silence. Avec strict_config = true (ou strict = true), cela devient ERROR: Unknown config option: testpath et l'exécution s'arrête, ce qui est ce que vous voulez.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml contient les deux tables. Déplacez tout dans une seule.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' Dans la table native [tool.pytest], addopts est une liste : addopts = ["-ra", "--strict-markers"]. La forme chaîne appartient à [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' Le pytest installé est plus ancien que ce dont le projet a besoin. Mettez-le à jour à l'intérieur de l'environnement virtuel.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. Rien de cette catégorie n'a été émis dans le bloc. Lisez Emitted warnings: en dessous — souvent le code émet un avertissement avec une autre catégorie, ou pas sur cette entrée.
Failed: Regex pattern did not match any of the 1 warnings emitted. Bonne catégorie, mauvais texte. match est une expression régulière, donc (, . et ? doivent être échappés — ou enveloppez le texte dans re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? Une faute de frappe dans la catégorie d'une ligne filterwarnings. pytest refuse de démarrer et imprime juste au-dessus la ligne qu'il n'a pas pu analyser.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Cela s'exécute avec pytest -q -s. Quel nombre le test imprime-t-il ?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B0
- C2
- D1
La racine du projet contient à la fois un pytest.ini et un pyproject.toml avec [tool.pytest.ini_options]. Que se passe-t-il lorsque vous exécutez pytest ?
- Apytest s'arrête avec une erreur signalant deux fichiers de configuration
- B`pytest.ini` est utilisé, les réglages de `pyproject.toml` sont ignorés, et l'en-tête en avertit
- C`pyproject.toml` l'emporte parce que c'est la norme la plus récente
- DLes deux fichiers sont fusionnés, `pyproject.toml` comblant les lacunes
La configuration contient addopts = -ra --strict-markers et n'enregistre que slow sous markers. Que se passe-t-il avec pytest -m "not slow" ?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- ALe test s'exécute, avec un avertissement dans le résumé
- BLe test est désélectionné, comme prévu
- CLa collection s'arrête : 'slwo' not found in `markers` configuration option
- DLe test est ignoré
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
Le projet library facture des frais de retard. src/library/fees.py :
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- Écrivez la table
[tool.pytest.ini_options]pour quepytestsimple, depuis la racine ou depuis l'intérieur detests/, importelibrary, collecte uniquementtests/, imprime les résumés-ra, rejette les marqueurs inconnus, échoue sur une réussitexfailinattendue et transforme chaque avertissement inattendu en erreur. Enregistrez un marqueurslow. - Écrivez d'abord le plan de tests — cas, entrée, attendu — couvrant le retour à temps, la frontière des 60 jours, 61 jours, un nombre négatif et le
fine()dépréqué. - Écrivez
tests/test_fees.pyà partir du plan, avec un testslowqui parcourt chaque jour d'une année. - Exécutez
pytest, puispytest -m "not slow", puiscd tests && pytest.
Puis deux expériences, en prédisant chaque résultat d'abord : remplacez le test deprecated_call par un simple appel à fine(3), et écrivez slow avec une faute de frappe : slwo.
Solution
Le plan :
| cas | entrée | attendu | | --- | --- | --- | | retour à temps | late_fee(0) | 0, aucun avertissement | | frontière, encore silencieux | late_fee(60) | 300, aucun avertissement | | juste au-delà de la frontière | late_fee(61) | 305 et un UserWarning mentionnant « lost book » | | entrée invalide | late_fee(-1) | ValueError mentionnant « negative » | | nom dépréqué | fine(3) | 15 et un DeprecationWarning mentionnant late_fee | | une année entière (lent) | late_fee(0) … late_fee(365) | dernier frais 1825, avertissements attendus |
pyproject.toml :
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py :
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sLes deux expériences :
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sLes raisons de chaque décision :
pythonpathettestpathsdans le fichier, pas sur la ligne de commande. C'est pourquoicd tests && pytestdonne les mêmes six réussites : les deux sont relatifs à la rootdir, que pytest trouve en remontant jusqu'àpyproject.toml.- Seulement des règles dans
addopts.-raet--strict-markerssont des politiques que tout le monde partage ;-vou-xsont des habitudes personnelles et restent sur votre propre ligne de commande. filterwarnings = ["error"]sans exceptions. Le projet n'a pas encore d'avertissements tiers, donc rien à excuser. Le jour où l'un apparaît, ajoutez une ligne étroiteignore::...:module, jamais unignoregénéralisé.- Le test des 60 jours n'a aucun code d'avertissement. Sous
error, tout avertissement à 60 jours le ferait échouer, donc la configuration garde la frontière. matchsur chaque vérification visant un message. « lost book » et « use late_fee » sont les mots sur lesquels un lecteur agit ; le reste peut être reformulé librement. Le test d'un an ometmatchexprès : il porte sur les frais, et il suffit qu'il dise que des avertissements y sont attendus.recorddans le test des 61 jours. Cela prouve qu'un appel émet un avertissement une fois, et non à chaque étape interne.- Les expériences. Sans
deprecated_call, la dépréciation est inattendue, doncerrorla transforme en échec — exactement ce que devrait faire l'intégration continue lorsque quelqu'un appelle une ancienne API. Le marqueur mal orthographié arrête la collection parce que--strict-markersest dansaddopts. (Ici, le filtreerrorl'aurait aussi attrapé, puisqu'un marqueur inconnu émet unPytestUnknownMarkWarning— mais la règle sur les marqueurs ne devrait pas dépendre de la politique sur les avertissements, donc les deux restent.)
É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