Marqueurs, skip et xfail — quels tests s'exécutent où
Dites à pytest de quoi chaque test a besoin et ce que vous en attendez avec skip, skipif, importorskip et xfail ; ce que signifient strict=True, raises= et XPASS ; enregistrez vos propres marqueurs et sélectionnez avec -m ; plus des fixtures paramétrées et usine.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Un petit projet, shop, avec un module et deux fichiers de test. money.py :
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)test_money.py le vérifie avec des valeurs simples. test_export.py lit les prix depuis YAML, ce qui nécessite le paquet tiers yaml :
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0Sur une machine où yaml n'est pas installé, pytest -q affiche :
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sLisez les deux dernières lignes. Interrompu. Les deux bons tests de test_money.py ne se sont jamais exécutés. Un fichier qui ne pouvait pas tourner sur cette machine a fait tomber toute l'exécution avec lui.
C'est la situation réelle dans n'importe quel projet qui vit assez longtemps. Certains tests ont besoin d'une bibliothèque qui est optionnelle. D'autres n'ont de sens que sous Linux. D'autres ont besoin d'une clé d'API que seule l'intégration continue possède. D'autres décrivent un bug que tout le monde connaît et que personne n'a encore corrigé. D'autres prennent une minute, et vous ne voulez pas attendre une minute à chaque fois que vous enregistrez un fichier.
Supprimer ces tests est une erreur, et les commenter aussi. Ce que vous voulez, c'est dire à pytest de quoi chaque test a besoin et ce que vous en attendez, et laisser pytest décider, exécution après exécution, quoi en faire. L'outil pour cela est le marqueur.
À la fin de ce chapitre, vous saurez
- Ignorer un test de façon inconditionnelle, sous une condition, ou depuis l'intérieur du test, et rendre la raison visible
- Ignorer tout un fichier lorsqu'un paquet optionnel manque, avec
pytest.importorskip - Marquer un bug connu avec
xfail, et le préciser avecstrict=Trueetraises= - Lire les lettres du rapport
s,xetX, et demander les détails avec-rs,-rx,-rA - Inventer vos propres marqueurs, les enregistrer, et attraper les fautes de frappe avec
--strict-markers - Sélectionner des tests avec
-m "not slow"et-m "slow and db" - Écrire une fixture qui exécute chaque test plusieurs fois, et une fixture qui renvoie une fonction
Prérequis : mocking.
Avant d'écrire le test
Les marqueurs ne portent pas sur ce qu'un test vérifie — les chapitres précédents ont traité cette question. Ils portent sur deux réponses que vous donnez avant d'écrire le test, et que la plupart des gens ne donnent qu'après qu'une exécution est devenue rouge pour la mauvaise raison.
1. De quoi ce test a-t-il besoin pour s'exécuter du tout ? Un paquet qui n'est pas une dépendance obligatoire, un système d'exploitation, une version de Python, une variable d'environnement contenant un secret, une base de données, un réseau, beaucoup de temps. Chacun de ces éléments est une précondition. S'il manque, le test n'a rien à dire : il n'a ni réussi ni échoué.
2. Que m'attends-je qu'il fasse aujourd'hui ? Normalement, réussir. Mais si le code a un bug connu et que vous écrivez le test qui décrit le comportement correct, vous vous attendez à ce qu'il échoue — et à ce qu'il échoue d'une manière particulière.
Les réponses déterminent le marqueur, écrivez-les donc d'abord. Pour le projet shop, le plan ressemble à ceci :
| Cas | Entrée / situation | Besoin | Attendu | Marqueur | |---|---|---|---|---| | prix simple | "1,250.50" | rien | 1250.5 | aucun | | export YAML | "pen: '15.00'" | le paquet yaml | 15.0 là où il est installé | pytest.importorskip("yaml") | | chemin POSIX | "data", "prices.csv" | pas Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | taux en temps réel | la vraie API | RATES_API_KEY défini | un taux, seulement là où la clé existe | pytest.skip() à l'intérieur du test | | symbole de devise | "$1,250" | rien | 1250.0 — aujourd'hui ValueError (bug n° 42) | xfail(raises=ValueError, strict=True) | | beaucoup de prix | 1000 chaînes | une seconde ou plus | la somme correcte | slow (personnalisé) |
Lisez le tableau par colonne. La colonne Besoin produit des abandons. La colonne Attendu produit des xfails. Le temps qu'il faut produit un marqueur personnalisé qui vous permet de choisir ce qui s'exécute.
Ce que vous devez avoir en place avant que tout cela ne fonctionne :
- un environnement virtuel avec
pytestinstallé, etmoney.pyimportable depuis les tests (exécution lancée depuis la racine du projet, comme dans les chapitres précédents) ; - un
pyproject.tomlà la racine du projet, car c'est là que les marqueurs personnalisés sont enregistrés ; - le bug écrit quelque part avec un numéro, pour que
reason=puisse le référencer.
Et ce qu'il ne faut pas marquer :
- N'ignorez pas un test parce qu'il échoue et que vous ne savez pas pourquoi. Un abandon masque l'échec à toutes les exécutions suivantes. Corrigez-le, ou comprenez-le suffisamment pour écrire
xfail(raises=...)avec une raison. - Ne marquez pas un test capricieux
xfail. Un test qui réussit parfois afficheraXcertains jours etxd'autres, et avecstrict=Trueil échouera au hasard. L'instabilité est un bug dans le test ; corrigez-le plutôt. - N'étiquetez pas tout
slow. Si la plus grande partie de la suite est désélectionnée à chaque exécution, l'exécution de tous les jours ne vérifie rien. Étiquetez les quelques tests qui coûtent réellement des secondes. - N'écrivez pas un test qui vérifie que pytest ignore correctement. Vous testez
money.py, pas pytest. Le marqueur est de la configuration ; le rapport d'exécution suffit pour prouver qu'il fonctionne.
Le reste du chapitre met ce tableau en œuvre, ligne par ligne.
skip — pas ce test, et voici pourquoi
D'abord la façon dont on le fait habituellement avant que personne ne connaisse les marqueurs — un return anticipé :
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sRéussi. Le test n'a rien vérifié du tout, et le rapport dit qu'il a réussi. Sur un ordinateur portable sans la clé, c'est un mensonge raconté à chaque exécution, et le point vert est indistinguable d'un vrai. La solution n'est pas d'éviter la condition, mais de la signaler honnêtement — sous la forme d'un abandon.
Le marqueur le plus simple est un décorateur qui dit « ne pas exécuter ceci », avec une raison :
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09sQuatre tests, quatre manières différentes de décider :
@pytest.mark.skip(reason=...)— toujours ignoré. Le corps ne s'exécute jamais.@pytest.mark.skipif(condition, reason=...)— ignoré lorsque la condition est vraie. La condition est évaluée lorsque le fichier est collecté. Cette exécution se faisait sous Linux, donctest_posix_paths'est exécuté et a réussi ; la version de Python était 3.12, donctest_new_featurea été ignoré.pytest.skip("...")appelé à l'intérieur du test — la décision est prise pendant que le test s'exécute. Utilisez-le lorsque vous ne l'apprenez qu'à mi-parcours, par exemple après avoir examiné l'environnement ou le résultat d'une fixture.
Chaque s de la ligne de progression est un test ignoré. Un abandon n'est pas un échec : l'exécution est verte.
Mais les raisons sont introuvables. Par défaut, pytest ne résume que les échecs et les erreurs. Demandez les abandons avec -rs :
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sC'est pourquoi le reason compte. Dans trois mois, « skipped » à lui seul ne dira à personne si le test peut être réactivé.
importorskip — quand tout un fichier a besoin d'un paquet
Retour à test_export.py. Remplacez import yaml par ceci :
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08spytest.importorskip("yaml") tente l'import. S'il réussit, il renvoie le module, d'où l'affectation à yaml. S'il échoue, tout le fichier est ignoré, avec l'erreur d'import comme raison. Les deux autres tests s'exécutent désormais. Installez yaml et le même fichier s'exécute intégralement, sans aucune modification.
xfail — un bug que vous connaissez
Un abandon dit « ce test ne peut pas s'exécuter ici ». Il existe une seconde déclaration, différente : « ce test s'exécute, et il échoue comme prévu, parce que le code a un bug connu ».
parse_price en a un. Elle ne gère pas un symbole de devise :
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx :
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sContrairement à un test ignoré, un test xfail s'exécute bien. Il a échoué, comme prédit, il apparaît donc avec un x minuscule et l'exécution reste verte. Le test est désormais le constat écrit du bug, posé dans la suite, plutôt qu'une note dans la tête de quelqu'un.
Pourquoi ne pas simplement l'ignorer ? Parce qu'un test ignoré ne s'exécute jamais, donc il ne peut jamais vous dire quoi que ce soit — ni quand le bug est corrigé, ni quand le code change au point d'échouer d'une nouvelle manière. Un test xfail s'exécute à chaque fois, et son résultat est comparé à ce que vous avez prédit. Skip, c'est pour « ne peut pas s'exécuter ici » ; xfail, c'est pour « s'exécute, et je sais ce qu'il va dire ».
Maintenant, quelqu'un corrige le bug n° 42 et modifie money.py pour retirer le $ :
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12sUn X majuscule, XPASS : « censé échouer, mais réussi ». Et l'exécution est toujours verte — le code de sortie est 0. Personne ne regarde une exécution verte, donc le marqueur reste, et le test continue d'affirmer un bug qui n'existe plus.
strict=True — annoncer la bonne nouvelle au grand jour
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sAvec strict=True, une réussite inattendue est un échec. Cela semble à l'envers, et c'est exactement ce que vous voulez : la personne qui a corrigé le bug est invitée à retirer le marqueur, et le test devient un test ordinaire qui protège la correction à partir de ce moment. Si vous voulez cela pour chaque xfail du projet, définissez xfail_strict = true dans la configuration de pytest.
raises= — échouer pour la bonne raison
Un xfail sans argument accepte n'importe quel échec. Y compris ceux que vous ne vouliez pas :
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09sLes deux tests ont la même faute de frappe, parse_prise. test_loose a avalé le NameError et a rapporté un x tranquille — il serait resté « censé échouer » pour toujours, sans jamais tester parse_price. test_precise a déclaré quel échec il attend, le ValueError que float("$1250") lève, donc toute autre exception est signalée comme un échec réel. Corrigez la coquille et les deux deviennent x.
Les lettres du rapport
La ligne de progression utilise un caractère par test, et -r prend les mêmes lettres pour choisir ce qui entre dans le résumé :
| Lettre | Signification | Affichée dans le résumé avec | |---|---|---| | . | réussi | -rp | | F | échoué | -rf (activé par défaut) | | E | erreur dans setup ou teardown | -rE (activé par défaut) | | s | ignoré | -rs | | x | xfailed — échoué comme prévu | -rx | | X | xpassed — réussi inopinément | -rX |
Les lettres se combinent : -rsx montre les abandons et les xfails ensemble. -rA montre tous les résultats, réussites comprises :
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sVos propres marqueurs
skip et xfail changent ce que pytest fait d'un test. Un marqueur peut aussi être une simple étiquette, avec un nom que vous inventez, qui ne change rien tant que vous ne l'utilisez pas pour choisir des tests. Disons que certains tests sont lents, et d'autres ont besoin d'une base de données :
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09sLes quatre ont réussi, et pytest se plaint de chaque utilisation. pytest.mark.<n'importe quoi> est accepté — l'attribut est créé sur place — donc pytest ne distingue pas slow d'un autre nom mal orthographié. Il vous demande de déclarer les noms que vous voulez utiliser.
Enregistrer les marqueurs
La liste va dans pyproject.toml, une chaîne par marqueur, le nom avant les deux-points et une description après :
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11sLes avertissements ont disparu, et pytest --markers les liste désormais pour la prochaine personne qui rejoint le projet :
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseSélectionner avec -m
Maintenant, les étiquettes justifient leur existence. -m prend une expression sur les noms de marqueurs, avec and, or, not et des parenthèses :
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07sDésélectionné n'est pas ignoré. Un test ignoré a été choisi puis a refusé de s'exécuter ; un test désélectionné n'a jamais été choisi. -m "not slow" est l'exécution que vous faites à chaque enregistrement ; l'exécution complète est pour l'intégration continue et juste avant de pousser.
--strict-markers — une faute de frappe devient une erreur
L'avertissement est facile à manquer. Voici un test ajouté plus tard par quelqu'un, avec le marqueur mal orthographié :
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07s-m "not slow" avait été demandé, et l'exécution a quand même pris une seconde : le test mal orthographié n'est pas slow, il s'est donc exécuté. Avec --strict-markers, un marqueur non enregistré arrête la collection à la place :
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sVous ne voulez pas taper ce drapeau à chaque fois, mettez-le donc dans addopts :
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — marquer tout un fichier
Lorsque chaque test d'un fichier a besoin de la base de données, décorer chacun est répétitif. Une variable de niveau module nommée pytestmark s'applique à tous les tests du fichier :
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07sLe nom doit être exactement pytestmark. Il peut aussi être une liste, pytestmark = [pytest.mark.db, pytest.mark.slow].
Des marqueurs sur un seul cas de parametrize
Un marqueur sur un test paramétré s'applique à tous les cas. Pour marquer un cas, enveloppez-le dans pytest.param(..., marks=...) :
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================Le bug connu vit à côté des cas qui fonctionnent, dans le même tableau, et le jour où il est corrigé, vous supprimez une seule enveloppe pytest.param.
Pour aller plus loin : des fixtures qui varient, et des fixtures qui fabriquent
Deux modèles de fixtures n'ont pas trouvé leur place dans les chapitres sur les fixtures, et les marqueurs sont une bonne occasion de les présenter, car les deux multiplient ou façonnent les tests que vous venez d'apprendre à sélectionner.
Une fixture paramétrée
parametrize fait varier les entrées d'un test. Parfois, vous voulez que chaque test qui utilise une fixture s'exécute une fois par valeur de cette fixture. Donnez à @pytest.fixture une liste params, et lisez la valeur courante depuis request.param :
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================Deux tests, trois valeurs, six exécutions. request est une fixture intégrée qui décrit le test en cours de préparation ; request.param n'existe que lorsque la fixture a params. ids nomme chaque valeur dans le rapport — sans cela, pytest construit un id à partir de la valeur elle-même, ce qui est difficile à lire pour des valeurs longues ou non imprimables. La liste peut aussi contenir des entrées pytest.param(...) avec marks=, exactement comme dans parametrize.
Là où cela trouve sa place : le même ensemble de tests appliqué à plusieurs backends, plusieurs formats de fichiers, plusieurs devises. Ajoutez une valeur à params, et chaque test qui utilise la fixture la couvre.
Une fixture usine
Une fixture ne peut pas prendre d'arguments de la part du test ; le test se contente de la nommer. Alors que faire lorsqu'un test a besoin de deux objets, construits différemment ? Renvoyez une fonction :
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sLa fixture s'exécute toujours une fois par test, et reçoit toujours ses propres fixtures (tmp_path ici, qui s'occupe aussi de supprimer les fichiers). Ce qu'elle remet, c'est _make, et le test l'appelle aussi souvent qu'il le veut, avec les arguments qu'il veut. Le nom make_... est la convention habituelle, et il indique au lecteur d'un coup d'œil que cette fixture est une fonction à appeler, et non une valeur toute faite.
Un exemple complet
Tout réuni dans un seul projet. pyproject.toml :
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py, utilisant le même money.py — toujours avec le bug n° 42 :
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")L'exécution courante, avec les raisons :
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sSeulement le test lent, et l'exécution hors ligne qui laisse de côté les tests lents et réseau :
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08sComptez-les : une fixture avec trois valeurs donne trois exécutions de test_share_is_positive, trois cas de test_share, un test lent et un test réseau — huit au total, ce que 1 passed, 7 deselected bien résume.
Et chaque résultat dit la vérité. Le test réseau ne fait pas semblant de réussir sans clé ; il dit qu'il a été ignoré et pourquoi. Le bug n° 42 est dans la suite, et parce qu'il est strict avec raises=ValueError, le jour où il est corrigé, l'exécution devient rouge et demande que le marqueur soit retiré.
En cas de problème
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Le marqueur n'est pas enregistré. Ajoutez-le à markers dans pyproject.toml. Si le nom est vraiment une faute de frappe, corrigez-le — et ajoutez --strict-markers à addopts pour que la prochaine soit une erreur plutôt qu'un avertissement.
`'slwo' not found in markers configuration option` C'est --strict-markers qui fait son travail : le fichier utilise un marqueur qui n'est pas dans la liste. Corrigez l'orthographe, ou enregistrez le nouveau nom.
8 deselected et no tests ran, code de sortie 5 -m n'a rien trouvé. Une faute de frappe dans une expression -m n'est pas une erreur, même avec --strict-markers — -m sloww se contente de ne sélectionner aucun test. Vérifiez le nom avec pytest --markers.
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input L'expression -m est incomplète. Mettez toute l'expression entre guillemets, et assurez-vous que chaque and/or a quelque chose de chaque côté.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") sans reason=. Une condition booléenne ne dit rien sur le pourquoi, donc pytest exige une raison.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") a été appelé au plus haut niveau d'un fichier. Pour un paquet manquant, utilisez pytest.importorskip ; sinon écrivez pytest.skip("...", allow_module_level=True), ou utilisez un skipif dans pytestmark.
[XPASS(strict)] bug #42 Une bonne nouvelle, signalée comme un échec : le bug est corrigé. Retirez le marqueur xfail et gardez le test.
Un test xfail qui reste x même après la correction du bug Il échoue pour une autre raison — souvent une faute de frappe dans le test lui-même. Ajoutez raises= avec l'exception que vous attendez réellement, et tout le reste apparaîtra comme un échec réel.
AttributeError: 'SubRequest' object has no attribute 'param' La fixture lit request.param, mais elle n'a pas de liste params=, et n'a pas été paramétrée autrement. Ajoutez params=[...] à @pytest.fixture.
fixture 'name' not found, en pointant sur une fixture Une fixture a été écrite comme def make_user(name):, dans l'espoir que le test transmettrait name. Les arguments de fixture sont d'autres fixtures, donc pytest est allé en chercher une appelée name. Transformez-la en usine : une fonction interne qui prend name, renvoyée par la fixture.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Que donnent la ligne de progression et la dernière ligne de pytest -q ? (Rappelez-vous que round(2.675, 2) donne 2.67.)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- A..x 2 passed, 1 xfailed
- Bs.X 1 passed, 1 skipped, 1 xpassed
- Cs.F 1 passed, 1 skipped, 1 failed
- Ds.x 1 passed, 1 skipped, 1 xfailed
slow est enregistré dans pyproject.toml, et --strict-markers est désactivé. Vous exécutez pytest -m "not slow", et l'exécution prend encore cinq secondes. Pourquoi ?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"` n'exécute que les tests lents
- BLe marqueur est mal orthographié `slwo`, le test n'est donc pas `slow` et il est sélectionné ; pytest ne fait qu'avertir
- C`time.sleep` fait ignorer les marqueurs par pytest
- D`-m` ne fonctionne qu'avec les marqueurs intégrés tels que `skip`
Le bug n° 12 a en fait déjà été corrigé, donc l'assertion est vraie. Que rapporte pytest -q ?
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed, code de sortie 0
- B`F` — FAILED avec `[XPASS(strict)]`, code de sortie 1
- C`s` — le test est ignoré parce qu'il est marqué
- D`x` — 1 xfailed, code de sortie 0
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
Prenez une suite de tests que vous avez déjà — d'un chapitre précédent, ou de votre propre travail — et donnez-lui un jeu honnête de marqueurs :
- Enregistrez deux marqueurs à vous dans
pyproject.toml(par exempleslowetnetwork), et activez--strict-markersdansaddopts. - Marquez au moins un test avec chacun. Utilisez
time.sleep(1)pour simuler un test lent si vous n'en avez pas. - Ajoutez un test qui a besoin d'une variable d'environnement, et ignorez-le depuis l'intérieur avec
pytest.skiplorsque la variable manque. - Trouvez, ou inventez, un bug dans votre code. Écrivez le test qui devrait réussir, et marquez-le
xfailavecstrict=Trueet unraises=précis. - Ajoutez une fixture avec
paramsetids, utilisée par au moins deux tests.
Puis exécutez, et enregistrez la sortie de chaque :
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
Enfin, deux expériences. Faites une faute de frappe dans l'un de vos marqueurs et exécutez une fois sans et une fois avec --strict-markers. Puis corrigez le bug de l'étape 4 et exécutez à nouveau. Lisez ce que pytest dit dans chaque cas — ces deux messages sont ceux qui gardent une suite truffée de marqueurs honnête, et ils sont plus faciles à reconnaître la deuxième fois si vous les avez déjà provoqués vous-même.
Solution
Une solution, utilisant money.py du projet shop. Le bug de l'étape 4 est un nouveau bug, le n° 51 : une cellule vide dans un tableur arrive sous forme de "", et devrait compter comme 0.0 — mais float("") lève ValueError.
pyproject.toml :
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py :
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.0Les trois exécutions :
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08sLa première expérience : @pytest.mark.slow mal orthographié @pytest.mark.slwo. Sans --strict-markers (ici désactivé avec -o addopts=""), l'exécution prend quand même une seconde, car le test « lent » n'a pas été désélectionné :
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sAvec cette option, l'exécution refuse de démarrer :
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sLa deuxième expérience : le bug n° 51 corrigé dans money.py :
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sRetirez la ligne xfail et le test réussit dès lors comme un test ordinaire.
Les raisons de chaque décision :
--strict-markersdansaddopts, pas sur la ligne de commande. Un filet de sécurité dont il faut se souvenir d'activer n'est pas un filet de sécurité. Dans la configuration, il s'applique à tout le monde, CI comprise.slowcomme étiquette, pas comme abandon. Le test lent est un bon test ; il devrait s'exécuter, juste pas à chaque enregistrement. Une étiquette vous permet de choisir ; un abandon le retirerait de toutes les exécutions.pytest.skip()à l'intérieur du test pour la clé d'API, passkipif. Les deux fonctionneraient ici, mais la vérification appartient au code qui a besoin de la clé. Lorsqu'un test n'apprend qu'à mi-parcours qu'il ne peut pas continuer, la forme interne est la seule qui fonctionne.raises=ValueErroretstrict=Trueensemble.raises=s'assure que le test échoue à cause du bug n° 51 et non à cause d'une faute de frappe ;strict=Trues'assure que le jour où le bug est corrigé soit remarqué. Chacun ferme une brèche différente par laquelle le marqueur pourrait se mettre à mentir.- Une fixture paramétrée, pas
parametrize, pour les formats. Deux tests partagent les mêmes trois entrées. Avec une fixture, la liste vit à un seul endroit ; ajouter un quatrième format l'ajoute aux deux tests d'un coup. - Les ids
whole,decimals,comma. Dans le rapport,[comma]indique de quoi le cas parle.[1,090]fonctionnerait aussi ici, mais les ids continuent de fonctionner lorsque les valeurs sont longues ou non imprimables.
É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