cours1 min de lecture

Un contrat, trois écritures : docstring, tests, assertions

Le contrat d'une structure lu du côté de qui écrit le code : la documentation qui le dit, le jeu de tests écrit avant la première ligne, l'assertion qui le garde du dedans.
programme

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.

ÉcritureOù elle vitCe qu'elle fait
La docstringdans le code, en françaisdit ce que la méthode promet
Le jeu de testshors de la classeéprouve l'implémentation sur des cas choisis
L'assertiondans la méthoderefuse un appel que le contrat interdit
Aucune de ces trois notions n'est neuve : spécification, docstring et 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éthodeReçoitRenvoieModifie la pile ?
empiler(x)un élément xrienoui : x passe au sommet
depiler()rienl'élément du sommetoui : il est retiré
sommet()rienl'élément du sommetnon
est_vide()rienun booléennon
len(p)rienle nombre d'élémentsnon

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 prometL'assert qui l'éprouve
une pile neuve est videassert p.est_vide()
après empiler(7) puis empiler("R"), le sommet est le dernier poséassert p.sommet() == "R"
sommet ne retire rienassert len(p) == 2
depiler rend le dernier élément empiléassert p.depiler() == "R"
… puis celui qui était dessousassert 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é"
Le test ne mentionne aucune implémentation. S'il écrivait 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.

⏵ Ctrl+↵ pour exécuter
Aucune exécution pour l'instant.

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 : depiler renvoie le plus ancien élément au lieu du plus récent ;
  • PileB : sommet retire l'élément qu'il renvoie ;
  • PileC : au-delà de trois éléments, la pile oublie le plus ancien, sans rien signaler.
⏵ Ctrl+↵ pour exécuter
Aucune exécution pour l'instant.

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 depiler commence 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 pour sommet(). Lever une exception explicite, comme le raise IndexError("pile vide") de la Pile du cours précédent, est une autre façon de l'interdire : le message nomme lui aussi le contrat.
  • La définir. depiler renvoie None sur 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.

⏵ Ctrl+↵ pour exécuter
Aucune exécution pour l'instant.

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 testL'assertion (précondition)
Où il vithors de la classedans la méthode
Ce qu'il accusel'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 ?nonoui (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 un SyntaxWarning: assertion is always true. Écrire assert x > 0, "message".
  • Confier une vérification de sécurité à assert. Python lancé avec l'option -O ignore 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.py exécute chaque ligne >>> et compare son résultat à la ligne suivante. Il n'affiche rien quand tout passe ; l'option -v détaille chaque exemple.
    def double(n):
        """Renvoyer le double de n.
    
        >>> double(3)
        6
        >>> double(-2)
        -4
        """
        return 2 * n
    
  • pytest découvre tous les fichiers test_*.py, exécute toutes les fonctions test_* qu'ils contiennent, et rapporte chaque assert qui 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.