parametrize — un test, plusieurs cas
Remplacer les tests copiés-collés et les boucles dans un test par @pytest.mark.parametrize. Identifiants de test, ids= et pytest.param, empilement de décorateurs, valeurs et erreurs dans un même tableau, et erreurs courantes.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une petite fonction qui convertit la note d'un examen en une appréciation, grading.py :
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C FBien la tester implique de vérifier de nombreuses notes, en particulier les valeurs limites : 80 et 79, 60 et 59, 40 et 39. La première idée qui vient à l'esprit consiste à copier un test et à modifier deux valeurs :
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sCela fonctionne, mais cela ne passe pas à l'échelle. Trois fonctions expriment la même chose — « cette note donne cette appréciation » — et seules deux valeurs diffèrent entre elles. Dix cas supplémentaires signifieraient dix copies de plus à synchroniser.
L'idée suivante consiste donc à créer une liste de cas et une boucle à l'intérieur d'un seul test. Deux des appréciations attendues ci-dessous sont volontairement fausses — 79 devrait être B, et 40 devrait être C :
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sCe rapport est pire qu'il n'y paraît. Il y avait deux cas erronés, et vous n'avez été informé que d'un seul : le premier assert qui échoue met fin au test, donc le cas pour 40 ne s'est jamais exécuté. Le rapport n'indique pas quelle note a échoué — vous devez déduire que 'B' == 'C' venait de 79. Et « 1 failed » comptabilise cinq cas comme un seul et unique résultat. Corrigez le premier, relancez les tests, et vous ne découvrirez qu'à ce moment-là le second.
Ce que vous voulez, c'est la concision de la boucle avec la clarté des rapports des copies : une seule fonction, plusieurs tests distincts.
À la fin de ce chapitre, vous saurez
- Exécuter une fonction de test sur plusieurs cas avec
@pytest.mark.parametrize - Transmettre plusieurs valeurs par cas et lire les identifiants de test générés par pytest
- Nommer les cas avec
ids=et avecpytest.param(..., id=...) - Marquer un cas isolé comme
xfailouskipsans modifier les autres - Combiner deux décorateurs parametrize pour obtenir chaque combinaison de leurs valeurs
- Mélanger des cas « renvoie une valeur » et « lève une erreur » dans un seul tableau
- Reconnaître les erreurs courantes de parametrize à partir de leurs messages d'erreur
Prérequis : comparer les floats avec `pytest.approx`.
Avant d'écrire le test
Parametrize est une façon de décrire un tableau. Le travail avant tout code de test consiste donc à construire ce tableau et à décider si un tableau est réellement la bonne forme à adopter.
1. Énoncer le contrat. grade(score) promet ceci : une note entière de 0 à 100 en entrée ; l'une des valeurs "A", "B", "C", "F" en sortie ; toute valeur en dehors de 0–100 lève une ValueError dont le message mentionne la note. La fonction n'a aucun effet de bord — aucun fichier, aucun affichage — il n'y a donc rien à initialiser ni à nettoyer. C'est ce qui en fait un cas idéal : chaque scénario se résume à « ces entrées, ce résultat ».
2. Vérifier votre environnement. Vous avez besoin de l'environnement virtuel activé, de pytest installé, et du fichier grading.py importable depuis le fichier de test — dans ces exemples, il se trouve à côté des tests dans le même dossier. Rien d'autre : parametrize est directement intégré à pytest, et contextlib fait partie de la bibliothèque standard.
3. Planifier les cas. Les limites sont ce qui compte le plus, car c'est là que se glissent les erreurs d'inattention entre >= et >. Chaque règle reçoit la valeur située de part et d'autre de sa frontière :
| Cas | Entrée | Attendu | |---|---|---| | haut de l'échelle | 100 | "A" | | A le plus bas | 80 | "A" | | B le plus haut | 79 | "B" | | B le plus bas | 60 | "B" | | C le plus haut | 59 | "C" | | C le plus bas | 40 | "C" | | F le plus haut | 39 | "F" | | bas de l'échelle | 0 | "F" | | juste en dessous de l'échelle | -1 | ValueError | | juste au-dessus de l'échelle | 101 | ValueError |
Chaque ligne devient un cas. La colonne « Cas » n'est pas décorative — elle deviendra plus tard l'identifiant du test, de sorte qu'un échec vous indique quelle règle a été enfreinte.
4. Décider de ce qu'il ne faut pas tester. Pas chaque note de 0 à 100 : 95 et 85 testent la même règle que 80, et augmentent le temps d'exécution sans apporter d'information supplémentaire. Une valeur de chaque côté de chaque frontière suffit. Ne testez pas les opérateurs de comparaison propres à Python, et ne placez pas deux comportements différents sous un même test simplement parce qu'ils rentrent dans un tableau — si les lignes nécessitent des assertions différentes, elles relèvent de tests différents.
Le reste de ce chapitre transforme ce tableau en code, un outil à la fois.
Un argument, plusieurs valeurs
Le décorateur prend le nom d'un argument et une liste de valeurs pour celui-ci. Pytest crée un test par valeur et lui transmet la valeur sous ce nom :
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================Une seule fonction, trois tests, chacun avec sa propre ligne et son propre nom. La partie entre crochets est l'identifiant de test, construit à partir de la valeur. score n'est pas une fixture et n'a pas de valeur par défaut — le nom figurant dans la chaîne du décorateur doit simplement correspondre à un paramètre de la fonction.
Plusieurs arguments par cas
La plupart des cas nécessitent une entrée et un résultat attendu. Nommez les deux, séparés par une virgule, et fournissez chaque cas sous forme de tuple dans le même ordre. Voici à nouveau la boucle, avec les deux mêmes résultats attendus erronés :
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sComparez cela avec la boucle. Chaque cas s'est exécuté. Les deux échecs sont signalés, chacun sous un identifiant — test_grade[79-C] — qui nomme le cas. Le décompte est fidèle : 2 failed, 3 passed. (Avec la trace d'appels longue par défaut, pytest affiche également les arguments de chaque cas en échec, score = 79, expected = 'C'.)
Les noms peuvent être écrits sous la forme d'une seule chaîne de caractères, "score,expected" ou "score, expected" — les espaces après les virgules sont autorisés — ou sous la forme d'une liste de chaînes, ["score", "expected"]. Les deux produisent exactement les mêmes tests. La chaîne est la forme la plus courante ; la liste s'avère pratique lorsque les noms sont générés par programmation.
Comment les identifiants sont générés
Pour les nombres, chaînes de caractères, booléens et None, pytest insère directement la valeur dans l'identifiant et relie les valeurs d'un cas par un tiret -. Pour tout le reste — une liste, un dictionnaire, un objet —, il ne cherche pas à le faire. Il utilise le nom de l'argument suivi de la position du cas :
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2 signifie « value et expected du cas numéro 2, en comptant à partir de zéro ». C'est unique, mais cela ne vous dit rien. Quand un tel cas échoue, vous devez parcourir la liste en comptant pour le trouver. C'est la raison pour laquelle il convient de nommer les cas vous-même.
Nommer les cas : ids=
Passez à ids= une liste de chaînes de caractères, une par cas, dans le même ordre :
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================À présent, la sortie explique pourquoi chaque cas existe. La faiblesse réside dans le fait que les noms résident dans une seconde liste, à l'écart de leurs cas : insérez un cas au milieu, oubliez d'ajouter son nom, et tous les noms suivants sont décalés d'un cran.
ids= accepte également une fonction. Pytest l'appelle une fois pour chaque valeur, et non pour chaque cas, puis assemble les résultats avec un tiret - :
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================Une fonction convient particulièrement aux valeurs dont l'identifiant automatique est inutile, comme les objets. Si elle renvoie None pour une valeur, pytest se rabat sur l'identifiant automatique pour cette valeur.
Un cas à la fois : pytest.param
pytest.param enveloppe un cas unique et vous permet d'y associer des éléments : un id= placé juste à côté de ses valeurs, et des marqueurs marks= qui s'appliquent à ce cas uniquement.
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================Le cas round-up formalise une décision pas encore tranchée — 59.5 doit-il être arrondi vers le haut pour donner un B ? — sans faire échouer l'exécution, et les autres cas restent intacts. (xfail et skip auront droit à leur propre chapitre plus tard ; pour le moment, sachez simplement qu'un marqueur peut cibler un cas précis.)
Puisque l'identifiant est un vrai nom, un cas peut être exécuté isolément : pytest "test_param_obj.py::test_grade_cases[perfect]". Mettez-le entre guillemets — le shell traite les crochets de façon spéciale.
Deux décorateurs : toutes les combinaisons
Empilez deux décorateurs parametrize et pytest exécute le test pour chaque combinaison de leurs valeurs — le produit cartésien. Deux devises multipliées par trois montants donnent six tests :
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================Le décorateur le plus proche de la fonction fournit la première partie de l'identifiant. N'empilez les décorateurs que lorsque les entrées sont réellement indépendantes — chaque montant doit fonctionner avec chaque devise. Si seules certaines paires ont du sens, listez ces paires sous la forme de tuples dans un seul décorateur. Et prenez garde à la multiplication : trois décorateurs de dix valeurs chacun représentent un millier de tests.
Valeurs et erreurs dans un seul tableau
Parfois, les cas se séparent en « renvoie ceci » et « lève cela ». Vous pouvez faire du résultat attendu lui-même un paramètre, et y entrer à l'aide de l'instruction with :
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontext est un gestionnaire de contexte qui ne fait rien : with nullcontext("C") as expected affecte simplement expected = "C", de sorte que les cas normaux exécutent l'assert comme d'habitude. Dans les cas d'erreur, pytest.raises fait office de gestionnaire de contexte : grade lève une exception à l'intérieur, l'assert ne s'exécute jamais jusqu'au bout, et le test réussit parce que la bonne erreur s'est produite. Si aucune erreur ne survient, vous obtenez le message bien connu DID NOT RAISE.
C'est une solution élégante pour un tableau court. Si le corps du test commence à exiger des instructions if pour distinguer les deux types de cas, séparez-le en deux tests distincts. Notez aussi les identifiants : expectation0 relève à nouveau de la règle des objets. L'exemple complet résout cela à l'aide de pytest.param.
Paramétrer une classe
Placez le décorateur sur une classe de test et chaque méthode de test qu'elle contient reçoit le paramètre :
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================Deux méthodes multipliées par deux valeurs : quatre tests. Chaque méthode de la classe doit accepter score, sinon la collecte échoue.
Un exemple complet
shipping.py :
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py :
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================Trois fonctions, quatorze tests, et chaque ligne précise ce qu'elle a vérifié. Trois points méritent votre attention.
Premièrement, les identifiants dans test_cost fournissent la raison d'être de chaque cas — part-kg-rounds-up explicite la règle protégée, ce que 1.2-local-70 ne ferait pas.
Deuxièmement, test_first_kg_is_flat empile les décorateurs parce que le poids et la zone sont réellement indépendants. Le test compare également la fonction avec elle-même plutôt qu'avec un nombre, si bien qu'il reste valable même en cas de modification des tarifs.
Troisièmement, pytest.param(..., id=...) transforme expectation2 en negative. Le tableau de valeurs et d'erreurs se lit désormais comme un cahier des charges.
En cas de problème
function uses no argument 'scores' Un nom dans la chaîne du décorateur ne correspond à aucun paramètre de la fonction — ici "scores,expected" face à def test_grade(score, expected). L'échec se produit à la collecte, avant même l'exécution du moindre test :
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found L'inverse : la fonction possède un paramètre que le décorateur ne nomme jamais, pytest cherche donc une fixture de ce nom et n'en trouve aucune. Ajoutez le nom dans la chaîne du décorateur.
the number of names (2) ... must be equal to the number of values (1) Un cas comporte un nombre incorrect de valeurs — par exemple (40,) dans une liste pour "score,expected" :
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)Des identifiants comme [score0] et une TypeError mentionnant un tuple Avec un seul nom d'argument, chaque valeur de la liste est transmise entière. Si vous écrivez des tuples par habitude, la fonction reçoit un tuple :
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================Les identifiants vous mettent sur la voie : un simple nombre aurait donné [80], donc [score0] signale que la valeur transmise était autre chose. Pour un seul argument, écrivez [80, 95].
Un cas réussit seul et échoue lorsqu'il est exécuté avec d'autres Les valeurs des paramètres sont créées une seule fois, lors de la collecte du module, et le même objet est transmis à chaque test qui le reçoit. Si un test modifie une valeur mutable — une liste, un dictionnaire —, le test suivant voit la modification :
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sPassez un objet immuable, tel que le tuple (), et construisez un nouvel objet au sein de chaque test avec cart = list(items). Dès lors, chaque test démarre avec sa propre liste vide et les deux réussissent.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
En exécutant pytest -v, quels sont les quatre identifiants de test qui apparaissent, et dans quel ordre ?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[1-x], test_pair[2-y]
- Btest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Ctest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Dtest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
grade(80) et grade(95) renvoient tous deux "A", et pourtant les deux tests échouent. Pourquoi ?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- ALes identifiants `score0` et `score1` entrent en conflit avec le nom de la fonction
- BLes valeurs doivent être un tuple de tuples, et non une liste
- CAvec un seul nom, chaque valeur est passée entière, donc `score` est le tuple `(80,)`, et non `80`
- Dparametrize ne peut pas être utilisé avec un seul nom d'argument
Deux résultats attendus sont ici erronés (79 devrait être B, 40 devrait être C). Que rapporte pytest ?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — une ligne par cas
- B1 failed — uniquement pour le premier cas erroné ; le cas avec 40 ne s'exécute jamais
- C1 failed — pour le cas avec 40, le dernier cas erroné
- D3 failed, car un seul échec fait échouer tous les cas
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 password.py avec une fonction check_password(text) qui renvoie une liste d'anomalies : "too short" en dessous de 8 caractères, "no digit" s'il ne comporte aucun chiffre, "no letter" s'il ne comporte aucune lettre. Un mot de passe valide renvoie []. Tout ce qui n'est pas une chaîne str lève une exception TypeError.
Écrivez ensuite test_password.py avec :
- Un test paramétré d'au moins six cas, écrit avec
pytest.paramet unid=qui indique ce que concerne chaque cas ("seven-chars","digits-only", etc.) - Deux décorateurs empilés — trois mots de passe valides multipliés par deux suffixes,
""et"!"— vérifiant que les six cas renvoient tous[] - Un tableau qui combine des cas normaux (
nullcontext) et des cas d'exceptionTypeError(pytest.raises) —Noneet l'entier12345678sont de bons exemples pour la seconde catégorie - Un cas marqué
xfailpour une règle que vous n'avez pas encore écrite, telle que « aucune lettre majuscule »
Lancez pytest -v et lisez la liste des identifiants comme si quelqu'un d'autre les avait écrits. Pourriez-vous déduire les règles du mot de passe uniquement à partir des identifiants ? Si ce n'est pas le cas, renommez les cas jusqu'à ce que ce soit limpide.
Ensuite, faussez délibérément un résultat attendu et relancez les tests. Vérifiez qu'une seule ligne échoue, que son identifiant vous indique de quel cas il s'agit, et que tous les autres cas ont continué de s'exécuter.
Solution
Commencez par le contrat, puis le tableau. check_password prend une chaîne str et renvoie la liste de toutes les anomalies détectées, dans un ordre fixe ; tout ce qui n'est pas une chaîne str lève une TypeError au lieu de renvoyer quoi que ce soit. password.py :
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py :
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================Pourquoi c'est écrit de cette façon :
- La paire à la frontière vient en premier.
eight-chars-oketseven-charsse placent de part et d'autre de la règle de longueur, là où une étourderie entre<et<=apparaîtrait. Chaque autre règle reçoit un cas qui n'enfreint que cette règle précise, de sorte qu'un échec pointe exactement vers une seule ligne decheck_password. emptyvérifie l'ordre de la liste. C'est l'unique cas qui enfreint les trois règles à la fois, ce qui confirme que les anomalies reviennent dans un ordre déterminé — un détail sur lequel un code appelant qui les affiche à un utilisateur pourra s'appuyer.- Les valeurs attendues sont de nouvelles listes pour chaque cas, et le test ne fait que les lire. Rien n'est modifié, donc le piège de l'objet partagé évoqué dans « En cas de problème » ne peut pas se produire ici.
- L'empilement convient parfaitement à
test_good_passwordscar le suffixe et le mot de passe sont indépendants : tout mot de passe valide doit rester valide avec ou sans point d'exclamation!. Les identifiantsabcdef12-etabcdef12-!sont compréhensibles sans aide, aucunids=n'était donc nécessaire. - Le tableau d'erreurs utilise des identifiants
pytest.paramcar les objetsnullcontextetpytest.raisesapparaîtraient sinon sous la formeexpectation0,expectation1.match="int"vérifie que le message mentionne bien le mauvais type, et pas simplement qu'une quelconqueTypeErrors'est produite. - Le cas
xfailformalise une règle future sans faire échouer l'exécution des tests. Quand la règle sur les majuscules sera ajoutée, ce cas commencera à réussir, et pytest le signalera sous forme deXPASS— le signal indiquant qu'il est temps de retirer le marqueur.
Lire les identifiants de haut en bas énonce les règles : huit caractères, au moins un chiffre, au moins une lettre, uniquement des chaînes de caractères, majuscules prévues. C'est le moyen infaillible de vérifier si les noms choisis sont suffisamment explicites.
É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