Les Pythons mangent-ils des concombres ? (deuxième partie)

Jeu 08 octobre 2009

Ceci est le deuxième article d'une série (qui devrait en comporter trois). Pour ceux qui ont loupé le début :

Considérons une calculatrice. En Python.

# fichier calculator.py
class Calculator(object):
    "Basic calculator"

    def __init__(self):
        self.args = []

On peut difficilement faire plus basique. Évidemment, cette calculatrice aura du mal à être effectivement fonctionnelle. Pour le moment, elle a une propriété interne (args) qui est une liste de valeurs.

Admettons que je veuille en faire une calculatrice qui sache faire des additions. C'est pour la beauté de l'exemple qu'on le garde le plus simple possible. Je décide (conception) que cette calculatrice doit avoir une méthode push et une méthode add.

La méthode push va ajouter le chiffre fourni à la pile de chiffres de ma calculatrice. (cette pile ne sera pour l'occasion qu'une liste. Même pas "Last In First Out".

La méthode add prendra tous les éléments de la liste, les additionnera, et renverra le résultat. Formidable.

Comme tout bon programmeur amoureux de la méthode TDD, j'écris d'abord mes tests. Comme suit :

# fichier : tests.py
import unittest
from calculator import Calculator

class CalculatorTest(unittest.TestCase):
    "Calculator test cases."

    def test_addition(self):
        """Testing the add method. This method should return the sum of the
        arguments"""
        cases = ( (20, 30, 50), (2, 5, 7), (0, 40, 40), )
        for number1, number2, total in cases:
            calc = Calculator()
            # push number in stack
            calc.push(number1)
            calc.push(number2)
            # actually testing the results
            self.assertEquals(calc.add(), total)

J'en conviens, c'est un peu idiot, écrit comme ça, mais on part du principe qu'on doit tester l'addition. Que fait-on précisément dans cette classe de tests ?

On a une liste de valeurs qui seront notre jeu d'essai : (20, 30, 50), (2, 5, 7), (0, 40, 40),. Les deux premiers nombres, une fois additionnés, donnent le résultat, c'est à dire le troisième nombre. Du moins, il faut s'assurer que de faire push du premier plus push du second et add doit donner le bon résultat. J'ai testé avec trois jeux de valeurs. Inutile d'aller au-delà pour le moment.

Si je lance le test dans l'état actuel des choses, ça beugue violemment. La classe Calculator ne possède pas de méthode "push", encore moins de "add". Alors j'implémente, et voici mon fichier "calculator" complet :

class Calculator(object):
    "Basic calculator"

    def __init__(self):
        self.args = []

    def push(self, value):
        "Append a value to the args stack"
        self.args.append(value)

    def add(self):
        "Sum all the previously pushed args"
        return sum(self.args)

Si je lance mes tests, ils passent avec succès. Ça tombe bien : 20 + 30 est bien égal à 50.

Il faut bien comprendre : utiliser unittest m'a permis de décrire un scénario :

soit un nombre, soit un autre nombre,
si je "clique sur add" ça devrait me donner un résultat

Ce scénario est simple, volontairement simpliste, mais il faut être persuadé qu'on peut décrire des scénarii beaucoup plus complexes ; en utilisant l'héritage on peut également rendre de plus en plus facile l'écriture de ces tests en Python et aller de plus en plus loin dans la complétude de ces tests. Parce que l'objectif de ces tests, c'est de TOUT tester. Pour le moment, on a vérifié les cas qui marchent "bien". Mais, comme je disais dans l'article précédent, tout en écrivant que 1+1 devait renvoyer 2, il est évident qu'une foule de cas litigieux me sont venus à l'esprit :

  • que doit-il se passer si une des valeurs fournie est négative ?
  • que doit-il se passer si une des valeurs fournie est un nombre, mais pas un entier (un float) ?
  • que doit-il se passer si une des valeurs fournie n'est pas un nombre ?
  • que doit-il se passer si on demande la somme sans avoir fourni de valeur au préalable ?

A chacune de ces questions, correspond un test. Et en écrivant le test, je dois décrire le comportement du programme. Pour certains c'est évident (dans le cas d'un nombre négatif, on fait l'addition normalement ; dans le cas d'une liste args vide, le résultat doit être zéro...). Mais dans le cas d'une chaîne de caractère ajoutée à un nombre entier...

C'est là qu'on voit un immense avantage de la méthode TDD : tu es obligé de réfléchir. Obligé de se poser des questions parfois embarrassantes, parce qu'elles décrivent des cas litigieux et qu'il faut trouver une réponse satisfaisante (c'est à dire, qui assure un comportement correct du programme). Sinon, y'a un bug. Parce qu'il faut être persuadé d'une chose : si utilisateur il y a, tu peux être sûr qu'il balancera des valeurs "imprévisibles" dans ton programme.

Cependant, l'écriture de ces tests me pose un petit soucis. Si je suis Pythonneux, tout le monde ne l'est pas. Et notamment mon "client". La personne qui m'a demandé une calculatrice a de fortes chances de ne pas être capable d'écrire ces tests. Et pourtant, ces tests doivent être le reflet exact de la pensée de mon client. Puisqu'ils répondent à sa demande et que c'est théoriquement lui qui m'a demandé quand on ajoute 1 et 1, ça renvoie 2 ; quand on ajoute 3 et -2, ça doit renvoyer 1 par exemple.

Mais ce n'est pas lui qui a écrit la classe de tests. C'est moi. Comment être sûr que ma classe correspond bien à sa vision de la chose ? Si je lui montre les résultats de mes tests, il n'est pas obligé de me croire sur parole. Il aurait beau jeu de dire : c'est bien gentil, ça, je vois 345 tests passés avec succès sur le rapport, mais comment être sûr que ces tests sont "honnètes" ?

Je peux aussi lui montrer ma classe de tests. Mouais. C'est du chinois pour lui. Dans le cas de ma calculatrice, on peut à la rigueur expliquer le programme point par point, mais dans le cas d'une application un peu plus compliquée, la lecture d'une classe de tests peut être incompréhensible au profane. Quant à expliquer point par point... N'y pensons pas.

Alors ? Comment faire ?...

Ça, tu le sauras peut-être lors du prochain épisode...