Avant d'écrire un test — ce dont vous avez réellement besoin
La réflexion qui précède toute ligne de code de test : transformer une exigence vague en contrat précis, rendre le code testable, choisir des cas avec des limites, connaître la réponse attendue sans exécuter le code et décider de ce qu'il ne faut pas tester — le tout en Python pur avec assert.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une fonction qui calcule la moyenne d'une liste de notes d'examen, ainsi que la manière dont la plupart d'entre nous vérifient une telle fonction — l'appeler deux ou trois fois et regarder :
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0Les deux réponses sont correctes. La fonction « fonctionne », et elle rejoint le reste du programme. Une semaine plus tard, une classe qui n'a pas encore de résultats atteint la même ligne :
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero« Ça fonctionnait quand j'ai essayé » était vrai. Mais cela signifiait seulement « ça fonctionne pour les deux entrées que j'ai essayées par hasard ». Une vérification manuelle présente deux faiblesses que cet exemple met en lumière.
La première, c'est qu'elle ne couvre que ce à quoi vous avez pensé sur le moment, et une liste vide est exactement le genre de chose à laquelle personne ne pense sur le moment.
La seconde, c'est qu'elle ne se répète pas. Les deux appels à print ont été exécutés une fois, lus une fois et effacés. Quand la fonction changera le mois prochain, personne ne les réexécutera.
Les tests automatisés corrigent la seconde faiblesse, et le reste de ce cours vous apprendra à les écrire avec pytest. Mais observez de plus près la première faiblesse, car aucun outil ne peut la corriger à votre place. Supposez que vous vouliez écrire une vérification pour la liste vide dès maintenant. Que dirait-elle ? Est-ce que average([]) devrait renvoyer 0 ? Renvoyer None ? Lever une erreur ? Personne ne l'a décidé. Vous ne pouvez pas écrire de test pour un comportement que personne n'a défini, et vous ne pouvez pas vérifier une réponse que vous ne connaissez pas déjà.
C'est tout l'objet de ce chapitre : la réflexion qui doit avoir lieu avant la première ligne de code de test. Faites-le correctement et les tests s'écriront presque tout seuls. Sautez cette étape et vous vous retrouverez avec des tests qui réussissent tout en passant à côté du bug.
À la fin de ce chapitre, vous saurez
- Définir ce qu'est un test en une phrase : une affirmation exécutable sur un comportement
- Transformer une exigence vague en un contrat précis — entrées, sortie, erreurs, effets de bord
- Reconnaître du code difficile à tester, et le restructurer en un cœur pur entouré d'une fine enveloppe
- Planifier des cas de test sous forme de tableau, avec des classes d'équivalence et des valeurs limites
- Déterminer les réponses attendues indépendamment du code — l'oracle
- Décider de ce qu'il ne faut pas tester, et savoir quand vous avez assez de tests
- Écrire le plan sous la forme de simples vérifications avec
assert, les exécuter et expliquer pourquoi elles ne suffisent pas encore
Prérequis : aucun — c'est ici que commence le cours. Vous avez seulement besoin de Python 3 et de savoir écrire une fonction. Vous n'avez pas encore besoin de pytest ; tout ce qui figure dans ce chapitre fonctionne avec Python standard.
Avant d'écrire le test
Chaque chapitre de ce cours comporte une section portant ce titre, et chacune d'elles répond aux six mêmes questions pour son sujet spécifique. C'est dans ce chapitre que ces questions trouvent leur source, les voici donc réunies au même endroit. Gardez-les précieusement : le reste du cours s'en servira de checklist.
- Qu'est-ce qui est promis exactement ? Le contrat : pour ces entrées, telle sortie ; pour ces entrées, telle erreur ; et tels effets de bord, ou aucun.
- Le code peut-il être appelé par un test ? Reçoit-il ses entrées en arguments et renvoie-t-il un résultat — ou bien lit-il le clavier, affiche-t-il du texte et consulte-t-il l'horloge ?
- Quels cas tester ? Un cas pour chaque type d'entrée, des deux côtés de chaque limite, les entrées invalides, les cas vides et les très grandes valeurs.
- Comment connaître la bonne réponse ? En la calculant à la main ou à partir de la spécification — jamais en exécutant le code testé.
- Que faut-il mettre en place ? La version de Python, un environnement, l'emplacement où résideront les fichiers de test — et, dans les chapitres suivants, les fichiers, réglages et outils nécessaires au sujet.
- Qu'est-ce que je ne testerai pas ? Python lui-même, les bibliothèques tierces, le code trop simple pour être faux, et les comportements que personne n'a promis.
Les voici appliquées à la fonction average vue plus haut, suffisamment courte pour être traitée en intégralité.
Le contrat comporte un angle mort, la première étape consiste donc à le combler. Supposons que vous posiez la question et qu'on vous réponde : « la moyenne d'une absence de notes n'a aucun sens ; levez une ValueError ». La fonction est déjà testable — elle prend une liste et renvoie un nombre. Pour ce chapitre, tout ce qui doit être en place est Python 3. Et voici les cas :
| Cas | Entrée | Attendu | Pourquoi | | --- | --- | --- | --- | | Typique | [80, 90, 70] | 80.0 | l'usage ordinaire : (80 + 90 + 70) / 3 | | Une seule note | [100] | 100.0 | la plus petite liste qui possède une moyenne | | Vide | [] | ValueError | le cas qui posait problème ; désormais tranché |
Ce qui ne figure pas sur la liste : le fait que sum additionne correctement, ou que / effectue la division — c'est le rôle de Python, et il est testé bien plus rigoureusement que tout ce que vous ou moi pourrions écrire.
Le reste de ce chapitre reprend chacune des six questions tour à tour, et se termine en déroulant la démarche complète sur une fonction réaliste.
Ce qu'est réellement un test
Un test est une affirmation exécutable sur un comportement : avec telle entrée, on attend tel résultat. Le « résultat » se présente sous exactement trois formes :
- une valeur de retour —
average([80, 90, 70])donne80.0 - une erreur —
average([])lève uneValueError - un effet de bord — après
add_score(scores, 70), la listescorescontient70à la fin
Python dispose déjà d'une instruction pour exprimer une affirmation : assert. Elle ne fait rien quand la condition est vraie et lève une AssertionError quand elle est fausse. Voici une affirmation de chaque type, avec average respectant désormais son contrat défini :
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedRemarquez ce dont chaque affirmation a besoin : une entrée que vous choisissez, un résultat que vous connaissez déjà, et un moyen d'appeler le code et d'observer ce qu'il a fait. Les six questions sont là pour vous assurer d'avoir ces trois éléments avant de commencer. Remarquez aussi à quel point la vérification d'erreur est laborieuse — cinq lignes de try/except/else pour simplement exprimer « cela devrait lever une exception ». pytest réduira cela à une seule ligne au chapitre quatre.
1. Le contrat : d'une exigence vague à une promesse précise
Les exigences arrivent généralement sous la forme d'une phrase : « Les membres bénéficient de 10 % de réduction sur les grosses commandes. » Cela semble complet. Donnez-la à deux développeurs consciencieux et observez :
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123Aucun des deux n'a commis d'erreur. La phrase ne disait pas si exactement 1000 compte comme une « grosse » commande, ni comment arrondir. Chacun a comblé le vide différemment, et une commande de 1000 reçoit une réduction de 0 de l'un et de 100 de l'autre. Aucun test ne peut dire lequel des deux a raison, car ce qui est « correct » n'a jamais été défini.
Un contrat est l'exigence rendue suffisamment précise pour être testée. Pour passer de la phrase au contrat, posez des questions — toujours les mêmes :
- Entrées. Quels types ? Quelle plage ? Y a-t-il une limite, et la limite est-elle elle-même incluse ? Qu'en est-il de zéro, des nombres négatifs, des cas vides, de
None? - Sortie. Qu'est-ce qui est renvoyé exactement — la réduction, ou le nouveau total ? De quel type ? Arrondi à combien de décimales ?
- Erreurs. Quelles entrées sont refusées, et comment — quelle exception ?
- Effets de bord. Est-ce que cela modifie quoi que ce soit — une liste passée en argument, un fichier, une base de données — ou affiche du texte ? Ou absolument rien ?
Posées à propos de la réduction, ces questions apportent des réponses, et les réponses vont là où se trouve le code — dans la docstring :
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5Chaque ligne de la docstring est désormais un élément qu'un test peut vérifier, et chaque vérification a une valeur attendue évidente. C'est là le test d'un bon contrat : une personne extérieure pourrait-elle écrire la valeur attendue pour n'importe quelle entrée, sans lire le code ? Si ce n'est pas le cas, il reste encore une question à poser.
Qui répond aux questions ? Quiconque est responsable de l'exigence — un product owner, un enseignant, un client ou vous-même. Quand il n'y a personne à qui demander, tranchez, écrivez la décision dans le contrat et passez à la suite. Une décision explicite peut être examinée et modifiée ; une décision implicite reste tapie dans le code jusqu'à ce qu'elle prenne quelqu'un par surprise.
2. Du code testable : un cœur pur et une fine enveloppe
Un test appelle une fonction avec des entrées choisies et compare ce qui lui est renvoyé. Certaines fonctions rendent cela impossible. En voici une, pour l'happy hour d'un café — 20 % de réduction sur les boissons entre 17:00 et 19:00 :
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")Elle fonctionne quand vous l'exécutez et que vous tapez un prix. Essayez maintenant de la tester depuis un autre fichier, check_drinks.py :
from drinks import drink_price
result = drink_price()
print("returned:", result)Une vérification automatisée s'exécute sans personne devant le clavier. < /dev/null fournit exactement cela au programme — une entrée sans rien dedans :
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a lineFournissez-lui un prix, et elle s'exécute — mais regardez ce qui revient :
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: NoneTrois problèmes distincts, tous très courants :
- Elle lit son entrée avec
input()au lieu de la recevoir en argument, ce qui empêche le test de choisir l'entrée. - Elle affiche son résultat au lieu de le renvoyer, donc le test reçoit
Noneet n'a rien à comparer. - Elle consulte l'horloge elle-même. Cette exécution a eu lieu juste avant sept heures du matin, la réponse a donc été
10.00; la même commande à dix-sept heures trente donne8.00. Une vérification réussirait ou échouerait selon le moment où elle est lancée — un test instable (flaky), ce qui est pire que de n'avoir aucun test, car les gens apprennent à l'ignorer.
La solution n'est pas une astuce de test. C'est un changement d'architecture : séparer la décision des entrées et sorties. La décision devient une fonction pure — tout ce dont elle a besoin lui parvient en arguments, la réponse en sort comme valeur de retour, et elle ne touche à rien d'autre. Le clavier, l'écran et l'horloge sont déplacés dans une fine enveloppe tout autour :
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()Désormais, les vérifications choisissent l'heure qu'elles veulent, à tout moment de la journée :
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedLe programme se comporte exactement comme avant pour la personne devant le clavier. Ce qui a changé, c'est que la partie qui mérite d'être testée — la règle — peut maintenant être testée isolément, et l'enveloppe est si fine qu'il n'y a presque rien dedans où l'on puisse se tromper.
Voici les signes avant-coureurs d'un code difficile à tester, afin que vous puissiez les repérer avant d'écrire un test :
| Signe | Pourquoi cela nuit au test | La solution habituelle | | --- | --- | --- | | input() au cœur de la logique | le test ne peut pas choisir l'entrée | la recevoir en argument | | print() comme seul résultat | rien ne revient pour comparer | renvoyer la valeur ; afficher dans l'enveloppe | | datetime.now(), random à l'intérieur | la réponse change d'une exécution à l'autre | passer l'heure ou la valeur aléatoire en paramètre | | lit ou modifie une variable globale | les tests s'influencent mutuellement | la passer en paramètre, renvoyer la nouvelle valeur | | ouvre un fichier ou une URL fixe à l'intérieur | le test a besoin de ce fichier ou du réseau | passer les données, ou le chemin, en paramètre |
Vous ne pouvez pas toujours restructurer le code — parfois il appartient à quelqu'un d'autre, ou bien les entrées/sorties sont le comportement lui-même. pytest propose des outils pour ces situations : capsys capture les sorties affichées (chapitre neuf), tmp_path fournit un dossier temporaire jetable (chapitre neuf), et monkeypatch ainsi que les mocks remplacent l'horloge, le réseau et l'environnement (chapitres dix et onze). Mais la restructuration vient d'abord. Un code facile à tester est généralement un code plus facile à comprendre.
3. Choisir les cas de test
Vous ne pouvez pas tester toutes les entrées ; drink_price à elle seule accepte n'importe quel prix à n'importe quelle heure. La tâche consiste à choisir un petit ensemble de cas où chacun peut intercepter une erreur que les autres manqueraient. Deux principes fondamentaux accomplissent l'essentiel de ce travail.
Les classes d'équivalence. Regroupez les entrées que le contrat traite de la même manière. Un seul membre d'un groupe représente tous les autres : si drink_price(10, 12) est correct, drink_price(10, 11) l'est presque certainement aussi, car c'est la même ligne de code qui gère les deux. Supposons que le contrat ait été resserré pour indiquer qu'une heure en dehors de 0–23, ou un prix négatif, lève une ValueError. Les heures se répartissent alors en cinq classes :
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalidLes valeurs limites. Les erreurs ne se répartissent pas uniformément au sein d'une classe. Elles s'accumulent sur ses bords, car c'est là que l'on choisit entre < et <=, et que l'expression « jusqu'à 19:00 » est traduite en code. Donc, pour chaque limite, testez la valeur de chaque côté : la dernière d'une classe et la première de la suivante. Pour une limite L, cela signifie examiner L - 1, L et L + 1 ; pour les décomptes et les longueurs, 0 et 1 également.
Voici pourquoi « une valeur typique par classe » ne suffit pas. Ci-dessous, le test de l'happy hour est écrit avec une étourderie facile : 17 < hour au lieu de 17 <= hour :
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorUn cas de chaque classe : tous réussissent, et les clients qui arrivent à dix-sept heures précises paient le plein tarif. Le cas limite l'intercepte immédiatement.
Les classes et les limites couvrent la forme générale des entrées. Passez ensuite en revue ces questions, qui ont toutes déjà permis de débusquer de vrais bugs :
- Entrée invalide — ce que le contrat impose de refuser : un prix négatif, l'heure
24. - Vide et zéro —
[],"",0, un prix de0. Est-ce que « rien » se comporte correctement ? None— uniquement si le contrat en fait mention. S'il n'en parle pas, ce n'est pas un cas à tester (voir question six).- Grandes valeurs — une très longue liste, un très grand nombre. Tout ce qui a une limite doit être testé à la limite.
- Arrondi — lorsqu'un calcul produit plus de décimales que le résultat n'en conserve.
Mis bout à bout, tout cela forme un plan de test : une ligne par cas, rédigée avant toute ligne de code de test. La dernière colonne est la plus importante — une ligne que vous ne pouvez pas justifier est une ligne dont vous n'avez pas besoin.
| Cas | Entrée (price, hour) | Attendu | Pourquoi | | --- | --- | --- | --- | | Plus petite heure valide | (10, 0) | 10 | bordure de la plage valide | | Dernière heure au plein tarif | (10, 16) | 10 | sous la limite de 17:00 | | Première heure avec réduction | (10, 17) | 8.0 | sur la limite de 17:00 | | Dernière heure avec réduction | (10, 18) | 8.0 | sous la limite de 19:00 | | Retour au plein tarif | (10, 19) | 10 | sur la limite de 19:00 — « non comprise » | | Plus grande heure valide | (10, 23) | 10 | bordure de la plage valide | | Boisson gratuite | (0, 17) | 0 | prix nul | | Arrondi | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, arrondi à 2 décimales | | Heure trop basse | (10, -1) | ValueError | juste en dehors de la plage | | Heure trop haute | (10, 24) | ValueError | juste en dehors de la plage | | Prix négatif | (-1, 12) | ValueError | entrée invalide |
Et voici le plan traduit en vérifications, pour la fonction resserrée :
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passedOnze lignes, et chacune est là pour une raison que vous pouvez formuler à voix haute.
4. L'oracle : connaître la réponse sans le code
Chaque vérification compare la réponse du code avec une réponse attendue. La source de cette réponse attendue s'appelle l'oracle, et il obéit à une règle unique : ce ne doit pas être le code testé.
Cela paraît trop évident pour être précisé, jusqu'à ce que l'on voie avec quelle facilité on enfreint cette règle. Ici, member_discount comporte un bug — 1 % au lieu de 10 % — accompagné de deux vérifications :
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLa première vérification compare la fonction avec elle-même. Elle réussit pour la fonction correcte, pour cette version défectueuse et pour n'importe quelle autre version que quiconque pourrait écrire — elle ne peut jamais échouer, elle ne vous apprend donc rien.
Personne ne l'écrit de manière aussi grossière. La même erreur survient généralement sous l'un de ces deux déguisements :
- Copier la sortie. Vous exécutez la fonction, elle affiche
20.0, et vous collez20.0dans le test. Si le code était déjà faux, la vérification protège désormais le bug. - Répéter la formule. Vous écrivez la valeur attendue sous la forme
2000 * 0.01— le calcul même qu'effectue le code. Une copie de la logique du code partage les erreurs du code. (La section « Quand ça plante », plus bas, montre cela en action.)
De bons oracles, grosso modo du plus fréquent au plus rare :
- Calculer à la main à partir du contrat. Choisissez des entrées qui facilitent cela : 2000 plutôt que 1873.41, de sorte que « 10 % de cette somme » puisse se calculer de tête.
- Les exemples de la spécification. Si l'exigence dit « une commande de 1500 bénéficie d'une réduction de 150 », cette ligne devient un test.
- Des faits connus. L'eau bout à 100 °C et 212 °F ; une liste vide est de longueur 0.
- Une méthode différente et éprouvée. Un calcul plus simple mais plus lent, ou une fonction de la bibliothèque standard effectuant la même tâche autrement :
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.mean a été écrit par d'autres personnes, d'une manière différente : tomber d'accord avec lui a donc du sens. (Comparer des nombres décimaux avec == peut être trompeur — 0.1 + 0.2 == 0.3 vaut False en Python. Les valeurs choisies ici ne posent pas de problème ; le chapitre cinq montre comment comparer correctement des flottants.)
Si vous ne parvenez pas à calculer la réponse attendue par l'un de ces moyens, ce n'est pas un problème de test. Cela signifie que vous ne savez pas encore ce que le code doit faire — revenez au contrat.
5. Ce qui doit être en place
Pour ce chapitre, uniquement Python 3. Vérifiez la version dont vous disposez :
$ python3 --version
Python 3.12.3Ce cours utilise Python 3.12, et n'importe quelle version à partir de la 3.10 permettra d'exécuter la quasi-totalité des exemples. Sous Windows, la commande est généralement py --version.
À partir du prochain chapitre, trois éléments supplémentaires devront être en place avant de pouvoir lancer un test, et le chapitre un configure chacun d'entre eux :
- Un environnement virtuel pour le projet, afin que l'outil de test soit installé dans le même Python que celui qui exécute votre code.
- pytest installé à l'intérieur.
- Un emplacement pour les tests. Les fichiers de test se placent à côté du code, ou dans un dossier
tests/, avec des noms commençant partest_. Le chapitre un commence par la première option ; le chapitre trois adopte la seconde et en explique les raisons.
Les chapitres suivants ajouteront leurs propres besoins — un fichier de configuration, un plugin, des données d'exemple sur le disque — et chacun les listera dans sa propre section « Avant d'écrire le test ».
6. Ce qu'il ne faut pas tester, et quand s'arrêter
Un test incapable de détecter une erreur dans votre code coûte du temps à écrire, du temps à exécuter et du temps à maintenir, sans rien apporter en retour. Laissez ces éléments de côté :
- Python lui-même. Pas la question de savoir si
sortedtrie ou siroundarrondit. Testez que votre fonction trie les bons éléments dans le bon ordre. - Les bibliothèques tierces. Pas la question de savoir si
requestsenvoie une requête ou sipandaslit un fichier CSV. Testez ce que votre code fait du résultat — et si vous devez vous assurer du comportement d'une bibliothèque, une seule petite vérification qui documente votre hypothèse suffit. - Le code trop simple pour être faux. Une fonction qui renvoie une constante, ou une classe qui ne fait que stocker ses arguments. Si vous ne pouvez pas concevoir l'erreur, le test ne pourra pas la détecter.
- Un comportement que personne n'a promis. Si le contrat ne dit rien sur le passage d'une chaîne de caractères à
drink_price, un test ne ferait que figer ce qu'il se trouve faire aujourd'hui. Soit vous l'ajoutez au contrat — puis vous le testez —, soit vous le laissez de côté. - Le comment plutôt que le quoi. Pas la fonction utilitaire appelée en interne, ni l'ordre dans lequel elle a exécuté ses étapes. Testez ce qui entre et ce qui sort ; l'intérieur pourra alors être réécrit librement, et les tests prouveront que tout fonctionne toujours.
Et combien de tests suffisent ? Il n'y a pas de chiffre magique, mais il y a une règle. Vous en avez assez lorsque :
- chaque classe d'équivalence possède au moins un cas,
- chaque limite possède un cas de chaque côté,
- chaque erreur promise par le contrat possède un cas,
- et chaque bug que vous avez déjà corrigé possède son propre cas, afin qu'il ne puisse pas réapparaître en silence.
Ensuite, arrêtez-vous. Pour chaque nouveau cas que vous êtes tenté d'ajouter, demandez-vous : quelle erreur intercepterait-il que les autres ne détecteraient pas ? Si vous ne pouvez pas en nommer une, c'est un doublon. drink_price(10, 11) à côté de drink_price(10, 12) n'intercepte rien de nouveau.
Un exemple complet
Appliquons maintenant l'ensemble de la démarche, du début à la fin, sur une fonction réaliste.
L'exigence, telle qu'elle a été transmise : « La livraison coûte 60 pour les colis locaux et 120 pour les nationaux. Les colis plus lourds coûtent plus cher — 20 par kilo supplémentaire en local, 30 au national. Nous n'acceptons aucun colis de plus de 30 kg. »
Les questions, et les réponses obtenues :
| Question | Réponse | | --- | --- | | Que couvre le tarif de base ? | Le premier kilogramme — 1 kg tout rond relève encore du tarif de base. | | 1,2 kg est-il facturé comme 1 kg ou comme 2 ? | Tout kilogramme entamé compte : 1,2 kg est facturé comme 2 kg. | | 30 kg tout rond est-il accepté ? | Oui. 30 est autorisé ; tout ce qui est au-dessus est refusé. | | Qu'en est-il de 0 kg, ou d'un poids négatif ? | Refusé — c'est une erreur de saisie. | | Comment les zones s'écrivent-elles ? | Exactement "local" ou "national". Tout le reste est refusé, y compris "Local". | | Qu'est-ce qui est renvoyé ? | Le coût sous la forme d'un nombre entier. Rien n'est affiché ni enregistré. |
Le contrat et la fonction testable, shipping.py. Elle reçoit tout en arguments et renvoie un nombre ; il n'y a pas d'enveloppe à séparer, car elle ne contient absolument aucune entrée ni sortie :
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceil arrondit à l'entier supérieur — math.ceil(1.2) donne 2 —, ce qui correspond exactement à la règle « tout kilogramme entamé compte ».
Le tableau des cas. Les classes de poids sont : invalide (0 et en dessous), le premier kilogramme de base (strictement supérieur à 0, jusqu'à 1), les kilogrammes supplémentaires (strictement supérieur à 1, jusqu'à 30), invalide (supérieur à 30). Les zones sont "local", "national", et n'importe quoi d'autre. Chaque valeur attendue est calculée à la main à partir du contrat, pas à partir du code :
| Cas | Entrée | Attendu | Pourquoi | | --- | --- | --- | --- | | Colis léger | (0.5, "local") | 60 | à l'intérieur du premier kilo de base | | Exactement 1 kg | (1, "local") | 60 | bordure haute du tarif de base : 1 kg est inclus | | Juste au-dessus de 1 kg | (1.2, "local") | 80 | kilogramme entamé comptabilisé : 60 + 1 × 20 | | Kilos entiers, local | (3, "local") | 100 | 60 + 2 × 20 | | Kilos entiers, national | (3, "national") | 180 | 120 + 2 × 30 — l'autre zone | | À la limite | (30, "national") | 990 | 120 + 29 × 30 ; 30 est accepté | | Zéro | (0, "local") | ValueError | bordure basse : 0 est refusé | | Négatif | (-2, "local") | ValueError | entrée invalide | | Au-delà de la limite | (30.5, "local") | ValueError | juste au-dessus de 30 | | Zone incorrecte | (2, "Local") | ValueError | pas exactement "local" |
Ce qui n'est délibérément pas dans le tableau : un poids égal à None ou "2". Le contrat indique que le poids est un nombre, ils sont donc hors périmètre — Python lèvera sa propre TypeError lorsqu'ils seront comparés à 0, et nous ne faisons aucune promesse au-delà. Pas math.ceil non plus : c'est l'affaire de Python, pas la nôtre. Et pas non plus (2, "local") à côté de (3, "local") : même classe, même ligne de code, rien de nouveau à intercepter.
Les vérifications, check_shipping.py — le tableau, ligne par ligne :
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedÀ présent, cassons-la. Quelques mois plus tard, quelqu'un nettoie shipping.py, supprime import math et écrit int(weight_kg) à la place de math.ceil(weight_kg). Le code se lit bien et s'exécute sans erreur. Relancez les vérifications :
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLe plan a rempli sa mission : la modification a été interceptée dès l'exécution des vérifications. int tronque les décimales au lieu d'arrondir à l'entier supérieur, donc 0,5 kg est devenu int(0.5) - 1, soit -1 kilo supplémentaire.
Mais lisez le rapport comme si vous ne le saviez pas d'avance, et notez le peu d'informations qu'il fournit :
- Quelle était la valeur réelle ? Le rapport montre la ligne de code, pas le nombre. Vous devez exécuter vous-même
shipping_cost(0.5, "local")pour découvrir que cela donnait40. - Qu'est-ce qui est cassé d'autre ? Le script s'est arrêté au premier échec. Le cas à 1,2 kg est faux lui aussi — il donne maintenant
60au lieu de80—, mais cette vérification n'a jamais été exécutée. - Quelles promesses ont tenu bon ? Rien ne vous indique si les cas d'erreur continuent de réussir.
- Et les vérifications d'erreurs ont nécessité une fonction auxiliaire avec
try/exceptjuste pour affirmer « cela doit lever une exception ».
La réflexion était la partie difficile, et elle est accomplie : le contrat, le tableau, l'oracle. Ce qui manque, c'est un meilleur exécuteur de tests — un outil qui trouve les vérifications tout seul, qui exécute chacune d'elles même après un échec, qui affiche la valeur réelle à côté de la valeur attendue, et qui vérifie les erreurs en une seule ligne. Cet outil, c'est pytest, et son installation fait l'objet du prochain chapitre.
Quand ça plante
Il s'agit ici d'erreurs de conception plutôt que d'erreurs Python, de sorte que les symptômes sont plus discrets : généralement un test qui réussit alors qu'il ne le devrait pas.
La valeur attendue répète la formule du code Le test est écrit en effectuant le même calcul que la fonction. Ici, la fonction présente le bug de int vu plus haut, et le test reproduit exactement le même :
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorC'est aussi à cela que ressemble « tester l'implémentation » : la vérification calque la manière dont le code opère au lieu d'énoncer ce qu'il promet. Écrivez la valeur attendue sous la forme d'un nombre brut issu du contrat.
La valeur attendue a été copiée depuis la sortie Vous avez exécuté la fonction, vu s'afficher 20.0, et vous l'avez collé dans le test. Il réussit — et si 20.0 était faux, il protège désormais le bug. Calculez chaque valeur attendue avant d'exécuter le code ; si votre résultat et celui du code divergent, cherchez lequel des deux a tort avant d'écrire quoi que ce soit.
Le plan ne contient aucun cas limite Tous les tests réussissent, et le bug se cache précisément sur le bord : à dix-sept heures, à 1 kg tout rond, à 12 ans. Un tableau qui ne contient que des lignes « typiques » en est la cause courante. Pour chaque <, <=, « jusqu'à », « à partir de » et « au moins » dans le contrat, il doit y avoir un cas de part et d'autre.
La fonction ne peut pas être appelée par une vérification EOFError: EOF when reading a line signifie que la fonction attend une saisie au clavier ; returned: None signifie qu'elle a affiché sa réponse au lieu de la renvoyer ; un test qui réussit le matin et échoue le soir indique qu'elle consulte l'horloge. Tous trois sont des problèmes de conception, résolus par la restructuration « cœur pur et fine enveloppe » de la section deux.
SyntaxWarning: assertion is always true, perhaps remove parentheses? Des parenthèses autour d'un assert et de son message les transforment en un tuple, et un tuple non vide est toujours considéré comme vrai :
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999 est manifestement faux, et le test a pourtant réussi. Écrivez assert drink_price(10, 17) == 999, "happy hour price" — sans parenthèses — et il échoue comme prévu. Python vous avertit ici ; lisez l'avertissement au lieu de le faire défiler sans y prêter attention.
Un test comparant des nombres décimaux avec == échoue pour une réponse correcte 0.1 + 0.2 == 0.3 vaut False en Python, car 0.1 + 0.2 donne en réalité 0.30000000000000004. La leçon de conception consiste à préciser dans le contrat comment les résultats sont arrondis, comme le font member_discount et drink_price. L'outil pytest dédié à la comparaison des flottants est abordé au chapitre cinq.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
L'exigence : « Les commandes de 500 ou plus sont livrées gratuitement. » Que se passe-t-il lorsque ce script s'exécute ?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- A`checks passed` est affiché, puis une `AssertionError` sur le dernier assert
- BUne `AssertionError` sur le premier assert, et rien n'est affiché
- CIl ne se passe rien — les asserts sont ignorés dans un script
- D`checks passed` puis `all done` sont affichés
Quelle fonction peut être testée de manière fiable, telle quelle, avec un assert d'une seule ligne ?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
Cette vérification est censée protéger la règle « 1,2 kg est facturé comme 2 kg ». Quel est le problème ?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- AElle devrait utiliser `is` au lieu de `==`
- BLa fonction doit être appelée à l'intérieur d'un `try`/`except`
- CLa valeur attendue provient du code testé, donc la vérification ne peut jamais échouer
- D`1.2` n'est pas une valeur limite, cela ne vaut donc pas la peine de la tester
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
Un cinéma vous transmet l'exigence suivante :
« Les enfants paient moins cher, les retraités bénéficient d'une réduction et les bébés entrent gratuitement. Les adultes paient le plein tarif, 300. »
Accomplissez chaque étape de ce chapitre, dans l'ordre, avant d'écrire la moindre vérification :
- Posez les questions. Listez au moins cinq points que cette phrase laisse en suspens — ce qu'on entend par « enfants », où commence la catégorie « retraités », si les limites sont incluses, quels sont les prix, ce qui se passe avec un âge impossible. Puis tranchez les réponses, comme le ferait le cinéma : moins de 3 ans est gratuit ; de 3 à 12 ans paie 150 ; de 13 à 64 ans paie 300 ; 65 ans et plus paie 200 ; les âges vont de 0 à 120 ans, et tout ce qui est en dehors lève une
ValueError. - Rédigez le contrat sous la forme de la docstring d'une fonction
ticket_price(age)danscinema.py. - Rendez-la testable : les arguments en entrée, une valeur de retour en sortie, aucun
input()et aucunprint(). - Dressez le tableau des cas : cas, entrée, attendu, pourquoi. Identifiez d'abord les classes d'équivalence, puis placez un cas de part et d'autre de chaque limite. Calculez chaque valeur attendue à partir du contrat.
- Décidez de ce que vous ne testerez pas, et expliquez pourquoi.
- Écrivez
check_cinema.pyavec un simpleassertpar ligne du tableau, et exécutez-le.
Introduisez ensuite une erreur volontaire dans la fonction : changez la règle des enfants de « 12 ans et moins » en « moins de 12 ans » — remplacez <= par <. Avant d'exécuter le script, prédisez quelle vérification échouera. Lancez-le et comparez.
Solution
1. Les questions, et les réponses décidées :
| Question | Réponse | | --- | --- | | Jusqu'à quel âge un bébé ne paie-t-il rien ? | Les âges 0, 1 et 2 ans. À partir de 3 ans, la place devient payante. | | Qu'est-ce qu'un « enfant » ? | Les âges de 3 à 12 ans, tous deux inclus. Tarif 150. | | Un jeune de 13 ans est-il un enfant ? | Non — 13 ans paie le tarif adulte, 300. | | Où commence la catégorie « retraités » ? 65 ans est-il inclus ? | 65 ans et plus. Tarif 200. | | Qu'est-ce qu'un âge impossible ? | Moins de 0 ou plus de 120 : ValueError. | | Qu'est-ce qui est renvoyé ? | Le prix sous la forme d'un nombre entier ; rien n'est affiché. |
2 et 3. Le contrat et la fonction, cinema.py. Les limites sont des constantes nommées, de sorte que le code se lit comme le tableau :
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. Le tableau des cas. Les classes sont : invalide (en dessous de 0), gratuit (0–2), enfant (3–12), adulte (13–64), senior (65–120), invalide (au-dessus de 120). Cela donne quatre cas typiques, huit cas limites et deux cas invalides :
| Cas | Entrée | Attendu | Pourquoi | | --- | --- | --- | --- | | Bébé typique | 1 | 0 | classe gratuite | | Enfant typique | 8 | 150 | classe enfant | | Adulte typique | 30 | 300 | classe adulte | | Senior typique | 80 | 200 | classe senior | | Nouveau-né | 0 | 0 | plus petit âge valide | | Dernier âge gratuit | 2 | 0 | sous la limite gratuit/enfant | | Premier âge enfant | 3 | 150 | sur la limite gratuit/enfant | | Dernier âge enfant | 12 | 150 | « 12 ans et moins » — la limite elle-même | | Premier âge adulte | 13 | 300 | juste après la limite enfant | | Dernier âge adulte | 64 | 300 | sous la limite senior | | Premier âge senior | 65 | 200 | « 65 ans et plus » — la limite elle-même | | Âge valide le plus élevé | 120 | 200 | sommet de la plage valide | | En dessous de la plage | -1 | ValueError | juste en dehors, côté bas | | Au-dessus de la plage | 121 | ValueError | juste en dehors, côté haut |
5. Non testé : une chaîne de caractères ou None pour l'âge — le contrat stipule un nombre entier, et ne promet rien d'autre. Un âge décimal comme 12.5 — de même. Les tarifs d'autres cinémas, ou le fonctionnement de <= en Python — ce n'est pas à nous de les tester. Et un deuxième cas typique au sein de la même classe, comme 9 à côté de 8 — cela n'intercepterait rien de nouveau.
6. Les vérifications, check_cinema.py :
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedL'erreur volontaire : if age <= CHILD_UP_TO: devient if age < CHILD_UP_TO:. Seul l'âge 12 change de classe — il bascule sur le tarif adulte —, donc seule la vérification pour 12 devrait échouer :
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLa ligne 23 est assert ticket_price(12) == 150, comme prévu.
Pourquoi procéder de cette manière :
- Les questions sont venues d'abord. « Enfants » et « retraités » ne sont pas des nombres ; chaque réponse du premier tableau a transformé un mot en une limite testable. Sans elles,
12et65auraient été de simples suppositions. - La fonction est pure. Un âge en entrée, un tarif en sortie, rien d'autre — chaque vérification tient donc en une ligne, et donne la même réponse à n'importe quelle heure du jour sur n'importe quelle machine.
- Des constantes nommées pour les limites.
CHILD_UP_TO = 12formalise en un seul endroit ce qu'indique le contrat, et les tests utilisent les mêmes valeurs. - Les lignes des cas limites ont fait tout le travail. Les quatre âges typiques — 1, 8, 30, 80 — continuent tous de réussir malgré le bug. Seule la ligne pour
12, la limite elle-même, l'a intercepté. C'est toute la démonstration de la valeur des cas limites en une seule exécution. - Chaque valeur attendue provient du contrat. Aucune d'elles n'a été copiée depuis l'exécution de
ticket_price, de sorte qu'une mauvaise réponse du code ne peut pas s'infiltrer dans les vérifications. - Le script s'est arrêté au premier échec, et n'a indiqué que la ligne. Vous saviez quel test avait échoué parce que vous l'aviez prédit — mais il ne vous a pas dit que le tarif obtenu pour 12 ans était 300. C'est précisément le manque que le prochain chapitre va combler.
É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