Conception de tests et TDD — ce qui fait un bon test
Planifier un test avant de l'écrire, Arrange-Act-Assert, tester le comportement plutôt que l'implémentation, rouge-vert-refactoring, tests basés sur des propriétés avec Hypothesis, causes et remèdes des tests instables, et un test de non-régression pour chaque bogue.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Vous connaissez maintenant tous les outils que ce cours voulait vous apprendre : assert, raises, parametrize, fixtures, mocks, marqueurs, configuration, couverture, plugins. Et il est tout à fait possible de les utiliser tous et de finir quand même avec des tests qui font plus de mal que de bien.
Voici un petit panier d'achat, avec deux tests qui passent :
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sMaintenant, quelqu'un améliore le panier. Ajouter deux fois le même article doit fusionner en une seule ligne, donc la liste devient un dictionnaire indexé par le nom. Le panier ajoute toujours des articles et les totalise toujours correctement — rien de ce qu'un client pourrait remarquer n'a changé :
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sUn test rouge, et pas de bogue. Le test ne vérifiait pas ce que le panier fait, mais comment le panier a été construit. Un test de ce type échoue quand vous améliorez le code et reste silencieux quand vous le cassez — l'exact contraire de son rôle.
Ce dernier chapitre ne parle pas d'un nouvel outil. Il parle de jugement : à quoi ressemble un bon test, comment laisser les tests guider le code, comment laisser l'ordinateur inventer des cas de test à votre place, et comment empêcher les tests d'échouer au hasard.
À la fin de ce chapitre, vous saurez
- Planifier un test avant de l'écrire : le contrat, les cas, et ce qu'on laisse de côté
- Structurer un test en Arrange, Act, Assert, et le nommer comme une phrase
- Distinguer un test de comportement d'un test d'implémentation, et réécrire le second en premier
- Travailler en cycles rouge-vert-refactoring, avec un vrai échec à chaque étape rouge
- Écrire des tests basés sur des propriétés avec Hypothesis et lire un exemple d'échec réduit
- Trouver et corriger les quatre causes habituelles des tests instables
- Transformer chaque rapport de bogue en test de non-régression
Prérequis : tests async, hooks et plugins.
Avant d'écrire le test
La plupart des mauvais tests ne sont pas mal écrits. Ils sont mal planifiés — écrits avant que quiconque ait décidé ce que le code promet. Donc avant tout code de test, quatre questions.
Tout au long de ce chapitre, nous allons construire une petite fonction en commençant par le test : slugify, qui transforme un titre d'article comme "Café au lait" en la partie d'URL "cafe-au-lait".
1. Quel est le contrat ? Énoncez-le en une ou deux phrases, tel que l'appelant le voit : étant donné un titre (une `str`), renvoyer un slug : des lettres ASCII minuscules et des chiffres, des mots reliés par un seul tiret, aucun tiret à aucune extrémité. Les lettres accentuées deviennent leur lettre simple. Aucun effet de bord — il ne touche ni fichier, ni horloge, ni réseau. Remarquez ce que le contrat ne mentionne pas : les expressions régulières, la normalisation Unicode, les fonctions auxiliaires. C'est la manière de faire, et elle peut changer librement.
2. Qu'est-ce qui doit être en place ? Un environnement virtuel avec les outils installés, et le code importable depuis les tests :
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisEnsuite deux fichiers côte à côte — slugs.py et test_slugs.py — et pytest lancé depuis ce dossier, pour que from slugs import slugify fonctionne. Ce sujet n'a besoin ni de fixtures, ni de fichiers temporaires, ni de variables d'environnement. Ça vaut aussi la peine d'être remarqué : une fonction pure est la chose la plus simple du monde à tester, ce qui est une bonne raison de placer la logique dans des fonctions pures.
3. Quels cas ? Le chemin nominal d'abord, puis les cas limites, puis les entrées invalides, puis les bornes :
| Cas | Entrée | Attendu | | --- | --- | --- | | mots ordinaires | "Hello World" | "hello-world" | | ponctuation | "Hello, World!" | "hello-world" | | lettres accentuées | "Café au lait" | "cafe-au-lait" | | espaces en trop aux extrémités | " Hello World " | "hello-world" | | les chiffres sont conservés | "Top 10 Tips" | "top-10-tips" | | rien d'utilisable | "!!!" | ? |
Ce point d'interrogation est la cellule la plus utile du tableau. En écrivant le plan, on a fait apparaître une décision que personne n'a prise : que doit-il se passer quand un titre ne contient aucune lettre ? Nous la laissons volontairement ouverte, et nous verrons plus loin dans ce chapitre ce que coûte une question restée ouverte.
4. Que ne testera-t-on pas ? Le module re de Python, unicodedata et str.lower() ont déjà leurs propres tests ; les retester ne fait que ralentir. Les fonctions auxiliaires privées (tout nom commençant par _) sont atteintes à travers slugify, jamais directement. Et vous n'essaierez pas de lister à la main chaque caractère Unicode — c'est le travail d'un test basé sur des propriétés, plus loin dans ce chapitre.
Le tableau devient les tests. Tout ce qui suit met en œuvre ce plan.
Arrange, Act, Assert
Chaque bon test a les mêmes trois parties, dans le même ordre :
- Arrange — construire le monde dont le test a besoin
- Act — faire l'unique chose testée
- Assert — vérifier ce qui en est sorti
Voici le panier testé à nouveau — cette fois à travers ce qu'il promet, et non à travers la façon dont il stocke les choses :
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0Face à la version à liste, puis à la version à dictionnaire :
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sMêmes tests, deux implémentations, tous verts dans les deux cas. C'est la propriété que vous voulez : un refactoring qui conserve le comportement doit garder les tests verts. Les commentaires du test du milieu ne sont là que pour montrer la forme ; la plupart des gens les suppriment et séparent les trois parties par des lignes vides, comme dans le troisième test.
Pourquoi est-il important que l'Act soit une seule étape ? Parce que quand le test échoue, vous voulez savoir quelle chose a cassé. Si l'Act comporte cinq appels, un échec désigne cinq suspects.
Tester le comportement, pas l'implémentation
La règle empirique : un test ne peut utiliser que ce qu'un appelant peut utiliser. Pour le panier, cela signifie Cart(), .add() et .total(). Pas _items, pas _lines. Le trait de soulignement initial est la façon de Python de dire « ceci est à moi, et je peux le changer ».
La mauvaise façon couple le test à une décision qui n'a jamais fait partie du contrat — le fait que les articles soient gardés dans une liste. La bonne façon pose la question qu'un appelant poserait : si j'ajoute ces articles, quel est le total ?
Deux signes qu'un test est couplé à l'implémentation :
- Il lit des attributs commençant par
_, ou il utilise un mock pour une fonction que le code appelle en interne - Un refactoring qu'aucun utilisateur ne remarquerait le fait passer au rouge
Les mocks méritent une remarque ici. Les mocks du chapitre onze sont le bon outil à une frontière — le réseau, l'horloge, un service de paiement. Utilisés à l'intérieur de votre propre code (« vérifier que _calculate a été appelée avec ces arguments »), ils deviennent des tests d'implémentation sous un autre nom.
Des noms qui se lisent comme des phrases, et une seule raison d'échouer
Lancez les trois tests du panier avec -v :
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]Lisez seulement les noms et vous avez la spécification du panier. test_add et test_total vous disaient seulement quelle méthode avait été touchée. Un bon nom énonce la situation et le résultat attendu : un panier vide totalise zéro. Il est long ; ce n'est pas grave. Vous ne le tapez jamais, et vous le lisez à chaque échec.
La seconde moitié de la règle est une seule raison d'échouer. Voici le contraire — un seul test qui vérifie tout, lancé contre un panier avec un bogue (il ignore quantity) :
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=no masque les traceback et ne laisse que le résumé — la partie que l'on lit en premier.) test_cart a échoué — ce qui ne dit rien — avec 865.0 == 880.0, qu'il faut maintenant décrypter. Et la troisième assertion n'a jamais été exécutée, donc vous ne savez pas si elle aurait réussi. Le même bogue face aux trois tests ciblés :
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sAvant d'ouvrir la moindre traceback, le résumé ressemble déjà à un diagnostic : le panier vide va bien, mais les quantités sont fausses. « Une seule raison d'échouer » ne signifie pas une seule ligne assert — deux assertions qui vérifient deux aspects du même résultat, c'est très bien. Cela signifie un seul comportement par test.
La pyramide des tests
Les tests existent en différentes tailles :
- Tests unitaires vérifient une fonction ou une classe, sans rien de réel autour. Quelques millisecondes chacun. Vous en avez des milliers.
- Tests d'intégration vérifient que les morceaux s'emboîtent : votre code face à une vraie base de données, un vrai système de fichiers, une vraie application HTTP via un client de test. Plus lents ; vous en avez des dizaines à des centaines.
- Tests de bout en bout pilotent tout le système comme le ferait un utilisateur — un navigateur, une API déployée. Lents et fragiles ; vous en avez une poignée, couvrant les parcours qui rapportent de l'argent.
Dessiné selon le nombre, cela forme une pyramide : une large base de tests unitaires, un sommet étroit de tests de bout en bout. La raison est le coût. Quand un test unitaire échoue, il désigne quelques lignes. Quand un test de bout en bout échoue, il désigne tout le système. Descendez chaque vérification aussi bas que possible dans la pyramide, et réservez le sommet à ce que seul le sommet peut voir.
Rouge, vert, refactoring
Le développement piloté par les tests (TDD) suit une boucle serrée :
- Rouge — écrire un petit test pour un comportement qui n'existe pas encore, et le voir échouer
- Vert — écrire le moins de code possible pour le faire passer
- Refactoring — nettoyer le code, les tests restant verts tout du long
Pourquoi le voir échouer ? Parce qu'un test que vous n'avez jamais vu échouer pourrait ne rien tester. L'étape rouge prouve que le test peut détecter l'absence de la fonctionnalité.
Nous prenons le plan du tableau, une ligne à la fois.
Rouge. La première ligne du tableau, avant que slugs.py n'existe :
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.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_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sC'est un rouge parfaitement valable. Le test demande quelque chose qui n'est pas là.
Vert. Le moins de code qui passe — et c'est vraiment le moins :
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sCela semble de la triche. C'est délibéré : le code ne fait que ce qu'un test a exigé jusqu'ici. Tout ajout serait du code qu'aucun test ne vérifie.
Rouge. La deuxième ligne du tableau :
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sLe ? marque exactement les deux caractères qui ne devraient pas être là.
Vert. Maintenant, l'astuce du remplacement d'un seul espace ne suffit plus. Remplacer chaque suite de « ni lettre ni chiffre » par un seul tiret, puis retirer les tirets aux extrémités :
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sLes lignes pour les espaces en trop et les chiffres passeraient déjà avec ce code. Ajoutez-les quand même — elles font partie du contrat — mais sachez qu'un test vert dès la première exécution n'a rien prouvé sur le code que vous venez d'écrire. Seul un test rouge puis vert le prouve.
Rouge. Lettres accentuées :
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sé ne fait pas partie de a-z, donc il a été jeté avec la ponctuation.
Vert. La normalisation Unicode « NFKD » découpe é en e plus une marque d'accent séparée ; l'encodage en ASCII avec "ignore" supprime ensuite la marque et garde le e :
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactoring. Ça fonctionne, mais le corps tient sur une seule ligne dense. Donnons un nom à chaque étape, compilons le motif une seule fois, et ajoutons des annotations de type — sans changer le comportement :
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================C'est l'étape qui rend le TDD rentable. Vous pouvez restructurer librement parce que les tests vérifient le comportement ; s'ils avaient touché à _to_ascii ou à l'expression régulière, le refactoring les aurait cassés.
Tests basés sur des propriétés avec Hypothesis
Chaque test jusqu'ici repose sur un exemple inventé : "Hello World", "Café au lait". Les exemples ne valent que ce que vaut votre imagination, et votre imagination partage ses angles morts avec le code que vous venez d'écrire.
Une propriété est une affirmation vraie pour toute entrée. Hypothesis génère les entrées — cent par test par défaut — et tente activement de trouver celle qui casse l'affirmation. @given indique d'où viennent les entrées ; strategies (toujours importé sous le nom st) décrit leur forme :
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sUne seule ligne de points, mais cent listes de prix et cent remises sont passées par là. Autres stratégies que vous utiliserez : st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...).
Quelles propriétés a slugify ? Vous ne pouvez pas dire ce qu'est le slug d'une chaîne aléatoire, mais vous pouvez dire à quoi il doit ressembler, et que slugifier un slug ne change rien :
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text() produit des emojis, du chinois, des caractères de contrôle, des chaînes vides — des entrées que vous n'auriez jamais tapées dans le tableau du plan.
Un aller-retour révèle un vrai bogue
La propriété la plus productive est l'aller-retour (round trip) : si vous pouvez encoder quelque chose puis le décoder à nouveau, décoder l'encodage doit redonner l'original. Voici un encodeur par longueurs de séquences (run-length) — "aaab" devient "3a1b" — avec deux tests d'exemple qui passent :
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sLe texte "0" s'encode en "10" — « un zéro » — et le décodeur lit 10 comme un nombre sans caractère après. Tout texte contenant un chiffre est corrompu. Les deux tests d'exemple n'utilisaient que des lettres, donc ils ne pouvaient pas le voir.
Réduction (shrinking)
Hypothesis n'est pas d'abord tombé sur "0". Il a trouvé une longue chaîne laide, puis l'a réduite : il a essayé des entrées plus petites et plus simples, en gardant chacune qui échouait encore, jusqu'à ce que plus rien de plus simple n'échoue. Vous pouvez voir cela se produire en enregistrant chaque entrée en échec :
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seed fixe les choix aléatoires pour que cette exécution soit reproductible ; database=None empêche Hypothesis de rejouer un échec mémorisé.) Le premier échec fait treize caractères de bruit avec un 9 caché dedans. Vous l'auriez fixé pendant des minutes. L'exemple réduit raconte toute l'histoire en un seul caractère : un chiffre casse tout.
Hypothesis enregistre aussi les exemples en échec dans un dossier .hypothesis/, donc la prochaine exécution essaie "0" en premier. Ajoutez ce dossier à .gitignore.
La correction place un séparateur entre le nombre et le caractère, pour qu'un chiffre ne puisse jamais être pris pour une partie du nombre. Et l'exemple trouvé par Hypothesis est fixé avec @example, pour qu'il tourne à chaque fois, sur chaque machine :
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)Les deux tests d'exemple passent au nouveau format ("3:a1:b"), et la propriété gagne une ligne :
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sLes propriétés ne remplacent pas les exemples. Les deux tests d'exemple documentent le format d'une façon qu'un lecteur comprend d'un coup d'œil ; la propriété protège les recoins auxquels personne n'a pensé. Utilisez les deux.
Tests instables (flaky)
Un test instable (flaky) passe et échoue sur le même code. C'est pire que pas de test : les gens prennent l'habitude de cliquer sur « relancer » et cessent de croire au rouge. Presque tous les tests instables viennent de l'une de quatre causes, et chacune a une correction qui supprime la cause au lieu de la masquer.
État partagé et ordre des tests
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sÉchoue dans la suite, passe seul. Le set au niveau du module survit d'un test à l'autre, donc le second test voit ce que le premier a laissé. Changez l'ordre, ou lancez les tests en parallèle avec pytest-xdist, et le résultat change. La mauvaise correction est de réordonner les tests. La bonne supprime l'état partagé : faites du registre un objet, et donnez à chaque test un registre neuf via une fixture :
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sQuand vous ne pouvez pas modifier le code, une fixture autouse qui réinitialise l'état avant et après chaque test est la solution de repli. Chaque test doit passer seul, et dans n'importe quel ordre.
Le temps
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonLe même code, lancé au même moment, sur deux machines réglées sur des fuseaux horaires différents :
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sLa correction est déjà dans la signature de la fonction : now peut être passé en paramètre. Un test qui contrôle l'horloge teste les deux côtés de la frontière, et donne la même réponse à n'importe quelle heure :
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sPasser l'heure en paramètre est plus simple que n'importe quel mock. Quand vous ne pouvez pas changer la signature, monkeypatch du chapitre dix remplace l'horloge à la place.
Le hasard
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"Six exécutions, rien n'a changé :
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sDeux corrections, pour deux types de questions. Affirmez ce qui est vrai pour chaque résultat possible — le gagnant est l'un des participants. Ou prenez le contrôle du hasard en passant un générateur initialisé avec une graine :
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sLe monde extérieur
La quatrième cause est tout ce que vous ne contrôlez pas : un vrai appel réseau, un vrai serveur, un sleep(0.1) qui « devrait suffire ». Les corrections sont celles des chapitres précédents — simuler à la frontière, utiliser tmp_path au lieu d'un dossier partagé, attendre une condition au lieu d'un temps fixe. Une seule forme couvre les quatre causes : tout ce dont le test dépend, le test doit le créer ou le contrôler.
Un test de non-régression pour chaque bogue
Revenons au point d'interrogation du tableau du plan. Personne n'y a répondu, et un rapport de bogue arrive : un article intitulé `"!!!"` a été enregistré dans `/posts/`, écrasant la page d'index.
Avant de toucher à slugify, écrivez un test qui reproduit le rapport et regardez-le échouer. Cela prouve que le test détecte ce bogue-là :
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sCorrigez ensuite — la fin de slugify devient :
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sLe test de non-régression et les trois tests d'exemple passent. Mais lancez toute la suite, et les tests de propriétés protestent :
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesis a essayé la chaîne vide, et slugify("") lève maintenant une exception. Ce n'est pas un nouveau bogue — c'est le contrat qui change volontairement, et les propriétés le remarquent. Très bien. Mettez-les à jour pour énoncer le nouveau contrat : des titres qui contiennent au moins une lettre ou un chiffre.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothing passe à @given(titles) de la même façon.
...... [100%]
6 passed in 0.01sPourquoi un test pour chaque bogue ? Parce qu'un bogue qui s'est produit une fois a prouvé qu'il est facile à commettre, et la prochaine personne qui touchera à ce code risque de le refaire. Le commentaire qui dit ce qui a mal tourné fait partie du test : dans un an, c'est la seule trace de la raison pour laquelle ce cas étrange est là.
Un exemple complet
Un vérificateur de force de mot de passe, construit avec tout ce chapitre. D'abord le plan :
| Cas | Entrée | Attendu | | --- | --- | --- | | vide | "" | weak | | tous les types, trop court | "Ab1!xyz" (7) | weak | | assez long, un seul type | "abcdefgh" | weak | | assez long, deux types | "abcdefg1" | medium | | 12 caractères, trois types | "abcdefghij1!" | strong | | 12 caractères, deux types | "abcdefghijk1" | medium |
passwords.py :
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py :
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================Trois décisions méritent d'être remarquées.
Les lignes du tableau se trouvent sur les frontières — 7 et 8 caractères, 11 et 12, deux types et trois. Les bogues vivent aux frontières : un < qui devrait être <= est invisible au milieu d'un intervalle.
La propriété dit quelque chose qu'aucun exemple ne peut dire : ajouter des caractères ne rend jamais un mot de passe plus faible. Personne n'écrirait ce test à la main pour chaque mot de passe, et Hypothesis le vérifie sur des centaines, y compris des lettres Unicode qui ne sont ni majuscules ni minuscules.
Et aucun test ne touche _kinds. On y accède à travers strength ; demain, il pourrait disparaître.
En cas de problème
AttributeError: 'Cart' object has no attribute '_items' après un refactoring Le test lisait l'intérieur de l'objet. Réécrivez-le pour vérifier à travers les méthodes publiques — ce qu'un appelant peut voir — et il survivra aussi au prochain refactoring.
Un test passe seul mais échoue dans l'exécution complète (ou l'inverse) État partagé : une liste, un dictionnaire ou un cache au niveau du module ; un fichier à un emplacement fixe ; une variable d'environnement définie et jamais réinitialisée. Faites en sorte que chaque test crée ce dont il a besoin (fixtures, tmp_path, monkeypatch) au lieu de réordonner les tests.
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. Un .filter() (ou assume()) jette presque tout ce qu'Hypothesis génère — par exemple st.integers().filter(lambda n: n % 1000 == 7). Construisez directement les valeurs voulues à la place : st.integers().map(lambda n: n * 1000 + 7).
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one Le test a donné une réponse différente pour la même entrée. Quelque chose hors des arguments générés a changé entre les appels — un compteur, une liste au niveau du module, l'horloge. Hypothesis rejoue chaque échec, donc un test qui a un état caché ne peut pas être réduit.
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. Chaque exemple généré doit se terminer en 200 ms par défaut, car cent exemples lents rendent une suite lente. Rendez le test plus rapide, ou, si la lenteur est réelle, augmentez la limite avec @settings(deadline=...).
Un nouveau test TDD passe dès la première exécution Il ne prouve rien sur le code que vous allez écrire. Soit le comportement existe déjà (très bien — gardez le test comme documentation), soit le test ne vérifie pas ce que vous croyez. Cassez le code volontairement un instant et vérifiez que le test passe au rouge.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Exécuté en un seul fichier, le second test échoue. Quelle est la dernière ligne quand on ne lance que le second test, seul ?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cart passe d'une liste à un dictionnaire en interne, et son comportement reste le même. Quel test passe au rouge ?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
Ce test passe du lundi au vendredi et échoue le samedi et le dimanche. Quelle est la meilleure correction ?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- APasser une date fixe — `is_weekend(date(2026, 10, 7))` — et ajouter un test séparé pour un samedi
- BÉcrire `== False` au lieu de `is False`
- CRelancer automatiquement le test à chaque échec
- DIgnorer le test les samedis et dimanches
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 deux fonctions, en commençant par le test, dans durations.py :
parse_duration(text)transforme"1h30m"en5400(secondes). Les unités sonth,m,s, dans cet ordre, chacune facultative ;"45s","2h"et"1h0m5s"sont toutes valides. Tout le reste —"","h","1x","30m1h","1.5h"— lèveValueErroravecnot a durationdans le message.format_duration(seconds)fait l'inverse :5400devient"1h30m",3605devient"1h5s",0devient"0s". Un nombre négatif lèveValueError.
Travaillez dans cet ordre :
- Écrivez d'abord le tableau du plan : cas, entrée, attendu — y compris les entrées invalides et les bornes (
59et60secondes, par exemple). - Parcourez chaque ligne en cycles rouge-vert. Lancez
pytestaprès chaque modification et regardez le rouge avant de le rendre vert. - Ajoutez la propriété aller-retour avec Hypothesis : pour tout nombre entier de secondes à partir de
0,parse_duration(format_duration(n)) == n. - Un utilisateur signale que
"90m"a été rejeté, alors que les gens le tapent tout le temps. Ajoutez un test de non-régression avec un commentaire qui dit ce qui a mal tourné.
Quand vous avez fini, faites un refactoring, et confirmez que toute la suite reste verte.
Solution
durations.py :
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py :
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sPourquoi c'est construit ainsi :
- Le plan est devenu les tables de
parametrize. Chaque ligne du tableau est une ligne deparametrize, donc ajouter un cas plus tard tient en une ligne, et les noms de tests dans la sortie-vmontrent quelle ligne a échoué. - Les entrées valides et invalides sont des tests séparés. Ce sont des comportements distincts, avec des raisons d'échouer distinctes. La liste des invalides contient un exemple de chaque type d'erreur : vide, unité sans nombre, unité inconnue, mauvais ordre, décimal.
match="not a duration"vérifie le message en plus du type, donc uneValueErrorlevée par accident ailleurs —int(""), par exemple — ne peut pas faire passer le test.if not textest là parce que le motif, avec chaque partie facultative, accepte la chaîne vide. C'est exactement le genre de cas que le tableau du plan vous force à examiner avant que l'expression régulière ne s'en occupe à votre place.59et60sont la frontière où les secondes basculent en minutes ; un test au milieu de l'intervalle ne détecterait jamais un décalage d'une unité ici.- La propriété aller-retour est le test le plus fort du fichier. Elle ne connaît aucune valeur attendue, et pourtant elle compare dix millions de durées possibles entre elles — tout désaccord entre les deux fonctions apparaît, réduit au plus petit nombre qui le casse.
- Le test de non-régression passe déjà avec cette implémentation ; il existe pour empêcher un futur « nettoyage » qui limiterait les minutes à
0-59de réintroduire le bogue. Son commentaire dit pourquoi. - Rien ne teste directement
PATTERNniUNITS. Ce sont de l'implémentation, et vous êtes libre de remplacer l'expression régulière par un analyseur écrit à la main demain.
Où aller ensuite
C'est la fin du cours. Vous savez écrire des tests, lire leurs échecs, les structurer, les isoler, les mesurer, étendre pytest lui-même, et — après ce chapitre — décider de ce qui vaut la peine d'être testé en premier lieu. Quelques pistes pour la suite :
- Faites-en une habitude sur du vrai code. Prenez un projet que vous avez déjà et ajoutez des tests à la partie que vous redoutez le plus de modifier. Écrivez d'abord le tableau du plan. La peur diminue à mesure que les tests augmentent.
- Allez plus loin avec Hypothesis. Sa documentation couvre les tests à états (
RuleBasedStateMachine), qui génèrent des séquences complètes d'opérations sur un objet, et trouvent des bogues qu'aucune entrée unique ne pourrait révéler. - Tests de mutation. Des outils comme
mutmutapportent de petites modifications à votre code —<vers<=,+vers-— et vérifient qu'un test échoue. Une mutation qui survit est une ligne que vos tests ne vérifient pas vraiment, quel que soit le taux de couverture affiché. - Faites grandir la mise en place de CI du chapitre quatorze. Lancez la suite sur chaque version de Python que vous supportez, et faites bloquer la fusion par un test en échec — une suite que personne n'est obligé de garder verte finit lentement par cesser d'être verte.
- Lisez de bonnes suites de tests. Les tests de
pytest,requests,attrsethypothesiseux-mêmes sont open source. Lire comment des gens expérimentés nomment, structurent et isolent leurs tests en apprend plus que n'importe quelle liste de règles.
Quoi que vous écriviez ensuite, partez de la question avec laquelle ce chapitre a commencé : si quelqu'un améliore ce code demain sans changer ce qu'il fait, mes tests resteront-ils verts — et s'il le casse, passeront-ils au rouge ?
É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