13.6. Tester la cohérence

Il est fréquent qu’une unité de code contiennent un ensemble de fonctions réciproques, habituellement sous la forme de fonctions de conversion où l’une converti de A à B et l’autre de B à A. Dans ce cas, il est utile de créer un test de cohérence pour s’assurer qu’une conversion de A à B puis de B à A n’introduit pas de perte de précision décimale, d’erreurs d’arrondi ou d’autres bogues.

Considérez cette spécification :

  1. Si vous prenez un nombre, le convertissez en chiffres romains, puis le convertissez à nouveau en nombre, vous devez obtenir la même valeur que celle de départ. Donc fromRoman(toRoman(n)) == n pour tout n compris dans 1..3999.

Exemple 13.5. Test de toRoman et fromRoman


class SanityCheck(unittest.TestCase):        
    def testSanity(self):                    
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):        1 2
            numeral = roman.toRoman(integer) 
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result) 3
1 Nous avons déjà vu la fonction range, mais ici elle est appelée avec deux arguments, ce qui retourne une liste d’entiers commençant au premier argument (1) et comptant jusqu’au second argument (4000) non compris. L’intervalle retourné est donc 1..3999, ce qui est l’étendue des valeurs pouvant être converties en nombre romains valides.
2 Juste une note au passage, integer n’est pas un mot-clé de Python, ici c’est un nom de variable comme un autre.
3 La logique de test elle-même est très simple : on prend une valeur (integer), on la converti en chiffres romains (numeral), puis on converti ce nombre en chiffres romains en une valeur (result) qui doit être la même que celle de départ. Dans le cas contraire, assertEqual déclenche une exception et le test sera immédiatement considéré comme ayant échoué. Si tous les nombres correspondent, assertEqual s’exécutera silencieusement, la méthode testSanity entière s’achèvera silencieusement et le test sera considéré comme ayant passé.

Les deux dernières spécifications sont différentes des autres car elles semblent à la fois arbitraire et triviales :

  1. toRoman doit toujours retourner des chiffres romains en majuscules.
  2. fromRoman doit seulement accepter des chiffres romains en majuscules (il doit échouer s’il lui est passé une entrée en minuscules.

En fait, elles sont un peu arbitraire. Nous aurions pu stipuler, par exemple, que fromRoman accepterait une entrée en minuscules ou en casse mélangée. Mais elles ne sont pas totalement arbitraire pour autant, si toRoman retourne toujours une sortie en majuscule, fromRoman doit au moins accepter une entrée en majuscules, sinon notre test de cohérence (spécification n°6) échouera. Le fait qu’il accepte seulement des majuscules est arbitraire, mais comme tout intégrateur système vous le dira, la casse est toujours importante, mieux vaut donc spécifier le comportement face à la casse dès le début. Et si cela vaut la peine d’être spécifié, cela vaut la peine d’être testé.

Exemple 13.6. Tester la casse


class CaseCheck(unittest.TestCase):                   
    def testToRomanCase(self):                        
        """toRoman should always return uppercase"""  
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            self.assertEqual(numeral, numeral.upper())         1

    def testFromRomanCase(self):                      
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            roman.fromRoman(numeral.upper())                   2 3
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower())   4
1 Le plus intéressant dans ce cas de test, c’est toutes les choses qu’il ne teste pas. Il ne teste pas que la valeur retournée par toRoman est correcte ni même cohérente, ces questions sont traitées par d’autres cas de test. Nous avons un cas de test uniquement consacré à la casse. On pourrait être tenté de le combiner avec le test de cohérence, puisque ces deux tests parcourent toute l’étendue des valeurs et appellent toRoman.[7] Mais cela serait une violation de nos règles fondamentales : chaque cas de test doit répondre à une seule question. Imaginez que vous combiniez cette vérification de la casse avec le test de cohérence et que le test échoue. Vous auriez à faire une analyse en profondeur pour savoir quelle partie du cas de test en serait la cause. Si vous devez analyser les résultats de vos tests unitaires rien que pour savoir ce qu’ils signifient, il est certain que vous avez mal conçus vos cas de test.
2 Il y a ici une leçon similaire : même si «nous savons» que toRoman retourne toujours des majuscules, nous convertissons explicitement sa valeur de retour en majuscules pour tester que fromRoman accepte une entrée en majuscule. Pourquoi ? Parce que le fait que toRoman retourne toujours des majuscules est une spécification indépendante. Si nous changions cette spécification de manière, par exemple, à ce qu’il retourne toujours des minuscules, le cas de test testToRomanCase devrait être modifié, mais celui-ci passerait toujours. C’est une autre de nos règles fondamentales : chaque cas de test doit fonctionner de manière isolée de tous les autres. Chaque cas de test est un îlot.
3 Notez que nous n’assignons pas la valeur retournée par fromRoman. C’est syntaxiquement légal en Python, si une fonction retourne un valeur mais que l’appelant ne l’assigne pas, Python se contente de jeter cette valeur de retour. Dans le cas présent, c’est ce que nous voulons. Ce cas de test ne teste rien qui concerne la valeur de retour, il teste seulement que fromRoman accepte une entrée en majuscule sans déclencher d’exception.
4 Cette ligne est compliquée, mais elle est très similaire à ce que nous avons fait dans les test ToRomanBadInput et FromRomanBadInput. Nous testons que l’appel d’une fonction spécifique (roman.fromRoman) avec une fonction spécifique (numeral.lower(), la version en minuscules des chiffres romains en cours dans la boucle) déclenche une exception spécifique (roman.InvalidRomanNumeralError). Si c’est le cas (à chaque itération de la boucle) le test passe, s’il se passe quelque chose d’autre ne serait-ce qu’une fois (par exemple le déclenchement d’une autre exception ou le retour d’une valeur sans déclencher d’exception) le test échoue.

Dans le chapitre suivant, nous verrons comment écrire le code qui passera ces tests.

Footnotes

[7] «Je peux résister à tout, sauf à la tentation.» Oscar Wilde