Introduction
Les cours précédents ont lu le contrat d'une structure du côté de qui l'utilise : l'interface dit ce qu'une pile ou une file promet, et deux implémentations différentes peuvent tenir la même promesse ().
Ce cours lit le même contrat de l'autre côté, celui de qui écrit le code. Vu de là, le contrat sert à deux choses. Il permet d'écrire le jeu de tests avant la première ligne de code, puisque les tests ne dépendent que de ce que la structure promet. Et il borne le périmètre du programme : il dit ce qu'il y a à écrire, donc à quoi on reconnaît que c'est fini.
Le fil conducteur tient en une phrase : un même contrat s'écrit trois fois, et les trois écritures disent la même chose.
| Écriture | Où elle vit | Ce qu'elle fait |
|---|---|---|
| La docstring | dans le code, en français | dit ce que la méthode promet |
| Le jeu de tests | hors de la classe | éprouve l'implémentation sur des cas choisis |
| L'assertion | dans la méthode | refuse un appel que le contrat interdit |
assert
ont été posés en Première (Spécification d'une fonction,
) et les jeux de tests aussi (Mise au point des programmes,
). Ce qui est neuf, c'est l'ordre : le test s'écrit en
premier, et l'assertion change de rôle selon l'endroit où elle vit.Première écriture : la docstring
Le contrat d'une pile tient dans un tableau : pour chaque opération, ce qu'elle
reçoit, ce qu'elle renvoie, et si elle modifie la pile. C'est l'interface de la
Pile du cours précédent, avec len(p) en plus (la méthode spéciale
__len__) pour pouvoir compter les éléments.
| Méthode | Reçoit | Renvoie | Modifie la pile ? |
|---|---|---|---|
empiler(x) | un élément x | rien | oui : x passe au sommet |
depiler() | rien | l'élément du sommet | oui : il est retiré |
sommet() | rien | l'élément du sommet | non |
est_vide() | rien | un booléen | non |
len(p) | rien | le nombre d'éléments | non |
Ce tableau est la documentation de la classe : il suffit de recopier chaque ligne dans la docstring de la méthode correspondante. Rien n'est ajouté, seule la place change.
class Pile:
"""Une pile : on n'accède qu'au dessus (LIFO)."""
def __init__(self):
"""Fabriquer une pile neuve, vide."""
def empiler(self, x):
"""Poser l'élément x sur le dessus de la pile."""
def depiler(self):
"""Retirer l'élément du sommet et le renvoyer."""
def sommet(self):
"""Renvoyer l'élément du sommet sans le retirer."""
def est_vide(self):
"""Renvoyer True si la pile ne contient aucun élément."""
def __len__(self):
"""Renvoyer le nombre d'éléments de la pile."""
Cette classe n'a aucun corps de méthode, et pourtant elle dit tout ce que
le contrat dit. La documentation se lit sans ouvrir le code :
help(Pile.depiler) affiche la phrase, et elle suffit pour utiliser la
pile.
Quatre mots pour parler d'un contrat :
- prototype : le nom d'une méthode, ce qu'elle reçoit, ce qu'elle renvoie ;
- précondition : ce que l'appel doit garantir avant (une pile non vide, par exemple) ;
- postcondition : ce que la méthode promet après, si la précondition est tenue ;
- docstring : le contrat écrit dans le code, entre triples guillemets.
Deuxième écriture : le jeu de tests
Une instruction assert est une affirmation : vraie, rien ne se passe ; fausse,
l'exécution s'arrête sur une AssertionError qui nomme ce qui a échoué. Un
jeu de tests est une suite d'affirmations tirées du contrat.
La traduction est mécanique : une promesse du contrat, un assert.
| Ce que le contrat promet | L'assert qui l'éprouve |
|---|---|
| une pile neuve est vide | assert p.est_vide() |
après empiler(7) puis empiler("R"), le sommet est le dernier posé | assert p.sommet() == "R" |
sommet ne retire rien | assert len(p) == 2 |
depiler rend le dernier élément empilé | assert p.depiler() == "R" |
| … puis celui qui était dessous | assert p.depiler() == 7 |
Deux lignes du tableau méritent qu'on s'y arrête.
Empiler deux éléments, pas un. Avec un seul élément, le premier entré et le dernier entré sont le même : une file passerait le test. Pour éprouver « dernier entré, premier sorti », il faut au moins deux entrées ; sinon, la propriété qui définit la pile n'est tout simplement pas mise à l'épreuve.
Tester une absence. « sommet ne retire rien » promet une absence d'effet,
et une absence ne se lit pas dans la valeur renvoyée : sommet() renvoie le
bon élément, qu'il le retire ou non. Il faut regarder ailleurs : ici, la
longueur de la pile, qui doit rester à 2.
Rassemblées dans une fonction, ces affirmations forment le test. Il reçoit en paramètre la classe à éprouver, ce qui permet de lui soumettre n'importe quelle implémentation :
def tester_pile(fabriquer_une_pile):
"""Éprouver une classe de pile. Ne connaît que le CONTRAT :
aucune mention de liste, d'append ou de pop."""
p = fabriquer_une_pile()
assert p.est_vide(), "une pile neuve est vide"
p.empiler(7)
p.empiler("R")
assert len(p) == 2, "deux empiler, deux éléments"
assert not p.est_vide()
assert p.sommet() == "R", "sommet renvoie le dessus"
assert len(p) == 2, "sommet ne retire rien"
assert p.depiler() == "R", "depiler renvoie le dessus"
assert p.depiler() == 7, "sous R, il y avait 7"
assert p.est_vide(), "tout est ressorti"
return "le contrat est honoré"
assert p.elements == [7, "R"], il ne testerait plus le contrat mais une façon
particulière de le réaliser, et rejetterait à tort une Pile correcte rangée
autrement.Pourquoi le test empile-t-il deux éléments avant d'appeler depiler() ?
Quel assert éprouve la promesse « sommet() ne retire rien » ?
Le test avant le code
Le jeu de tests ne dépend que du contrat. Il peut donc s'écrire avant la moindre ligne d'implémentation, et c'est précisément l'ordre recommandé.
Lancé sur la Pile sans corps de la première partie, le test échoue dès sa
première ligne : est_vide() renvoie None, qui compte comme faux, donc
assert p.est_vide() échoue.
Ce n'est pas un raté du test, c'est le résultat attendu.
Le repère à retenir : un bon jeu de tests échoue tant que le code n'existe pas, et passe une fois qu'il est juste. Entre les deux, chaque échec désigne ce qu'il reste à écrire. Le test devient alors la définition de « fini » : le travail s'arrête quand il passe, ni avant ni beaucoup après.
Un test qui passerait déjà sur une classe vide serait suspect : il n'éprouverait rien.
Le jeu de tests, lancé sur une classe dont les méthodes n'ont pas de corps, échoue. Qu'en conclure ?
Ce qu'un jeu de tests ne dit pas
Trois implémentations de Pile, chacune fausse à sa manière, passent devant le
même test, sans qu'il change d'une virgule :
PileA:depilerrenvoie le plus ancien élément au lieu du plus récent ;PileB:sommetretire l'élément qu'il renvoie ;PileC: au-delà de trois éléments, la pile oublie le plus ancien, sans rien signaler.
PileA et PileB sont rejetées : deux éléments suffisent à les démasquer.
PileC, elle, passe, et reste fausse. Le jeu de tests n'empile jamais
quatre éléments, donc il ne voit rien.
C'est la phrase du programme de Première, rencontrée en situation : le succès d'un jeu de tests ne garantit pas la correction d'un programme. Un test qui passe prouve seulement que les cas écrits passent. D'où l'importance du nombre et du choix des cas : ici, un cas « beaucoup d'éléments » manquait.
p = PileC()
for n in [1, 2, 3, 4]:
p.empiler(n)
assert len(p) == 4, "empiler quatre éléments en laisse quatre" # PileC échoue
Le périmètre, c'est ce que le contrat a dit
PileC n'est pas fautive par étourderie : elle honore un autre contrat,
celui d'une pile de capacité bornée, que le contrat écrit n'interdisait nulle
part. Le périmètre d'un programme n'est pas ce que son code fait : c'est ce que
son contrat a dit. Un contrat précis (« la pile n'a pas de limite de taille »)
aurait appelé le test qui manquait.
PileC passe le jeu de tests. Qu'en conclure ?
Troisième écriture : l'assertion dans la méthode
Le tableau du contrat ne dit pas ce que doit faire depiler() sur une pile
vide. Deux réponses sont également correctes, à condition d'être écrites.
- L'interdire. La pile non vide devient une précondition, et
depilercommence par la vérifier :def depiler(self): """Retirer l'élément du sommet et le renvoyer. Précondition : la pile n'est pas vide.""" assert not self.est_vide(), "depiler exige une pile non vide" return self.elements.pop()
La même précondition vaut poursommet(). Lever une exception explicite, comme leraise IndexError("pile vide")de laPiledu cours précédent, est une autre façon de l'interdire : le message nomme lui aussi le contrat. - La définir.
depilerrenvoieNonesur une pile vide, et la documentation le dit.
Ce qui serait fautif, c'est de ne rien dire : le code déciderait alors à la place du contrat, et personne ne saurait si le comportement obtenu était voulu.
La différence se voit dans le message d'erreur. Sans précondition écrite,
[].pop() lève IndexError: pop from empty list, un message qui parle des
entrailles de la classe (une liste, un pop). Avec l'assertion,
AssertionError: depiler exige une pile non vide parle de l'appel, et
nomme la promesse qui n'a pas été tenue.
Dedans ou dehors
Le jeu de tests et la précondition s'écrivent tous deux avec assert. Ce qui
les distingue n'est pas la syntaxe mais l'endroit où ils vivent, et donc
qui ils mettent en cause.
| Le test | L'assertion (précondition) | |
|---|---|---|
| Où il vit | hors de la classe | dans la méthode |
| Ce qu'il accuse | l'implémentation : « ce code est faux » | l'appelant : « cet appel n'avait pas le droit » |
| Quand il s'exécute | à la demande, pendant le développement, sur des cas choisis | à chaque appel, sur les vraies données |
| Part-il avec le programme ? | non | oui (sauf sous python -O) |
Le critère tient en une ligne :
Le test vit dehors et accuse l'implémentation.
L'assertion vit dedans et accuse l'appelant.
Quand assert not self.est_vide(), première ligne de depiler, lève une AssertionError, qui est mis en cause ?
La ligne assert p.depiler() == "R", écrite après avoir empilé 7 puis "R", est…
Remettre dans l'ordre les étapes d'écriture d'une structure à partir de son contrat :
Glisser-déposer pour réordonner (ou utiliser les flèches).
- 1. Écrire le corps des méthodes
- 2. Lancer le jeu de tests sur la classe vide et constater l'échec
- 3. Traduire chaque promesse en assert dans un jeu de tests
- 4. Relancer le jeu de tests jusqu'à ce qu'il passe
- 5. Écrire la docstring de chaque méthode
Pièges courants
- Tester l'implémentation au lieu du contrat.
assert p.elements == [7]rejette une pile correcte rangée autrement. Le test ne passe que par les méthodes de l'interface. - Un seul élément pour tester l'ordre. Pile et file rendent alors le même élément ; la propriété LIFO n'est pas éprouvée.
- Conclure d'un test qui passe que le code est juste. Il prouve seulement
que les cas écrits passent (
PileC). - Mettre des parenthèses autour d'un
assert.assert (x > 0, "message")teste un tuple non vide, toujours vrai : l'assertion ne saute jamais. Python le signale d'ailleurs par unSyntaxWarning: assertion is always true. Écrireassert x > 0, "message". - Confier une vérification de sécurité à
assert. Python lancé avec l'option-Oignore toutes les assertions. Elles documentent et gardent un contrat entre parties d'un même programme ; elles ne filtrent pas une saisie malveillante.
Pour aller plus loin
Deux outils industrialisent le jeu de tests.
- Les doctests placent les exemples directement dans la docstring :
première et deuxième écritures réunies.
python -m doctest fichier.pyexécute chaque ligne>>>et compare son résultat à la ligne suivante. Il n'affiche rien quand tout passe ; l'option-vdétaille chaque exemple.def double(n): """Renvoyer le double de n. >>> double(3) 6 >>> double(-2) -4 """ return 2 * n pytestdécouvre tous les fichierstest_*.py, exécute toutes les fonctionstest_*qu'ils contiennent, et rapporte chaqueassertqui a échoué.pytest.raises(AssertionError)vérifie qu'un appel interdit est bien refusé : c'est un test de la précondition.
C'est aussi le format de l'épreuve pratique du baccalauréat : une spécification donnée, des exemples, et du code à écrire jusqu'à ce qu'ils passent.