Un gros guide bien gras sur les tests unitaires en Python, partie 4


Python est un langage très pro, et il y a beaucoup, beaucoup d’outils pour faire des tests.

Après avoir vu pytest, un outil typiquement pythonique sont les doctests, des tests unitaires intégrés dans les docstrings.

Pour rappel, les docstrings, ce sont ces chaînes de caractères qu’on retrouve au début des modules, sous la signature des fonctions ou dans la définition des classes. Elles servent à la documentation de ceux-ci, ainsi on peut la lire dans le code, et dans les vraies docs car les outils standards savent les extraire.

Ça ressemble à ça :

def une_fonction():
    """ Ceci est une docstring.
 
        On peut la lire dans le code source, avec help() dans le shell ou
        dans les docs générés par pydoc et sphinx.
    """
    pass

Et bien ces docstrings, on peut mettre des tests unitaires dedans formatés comme des sessions de shell Python. Cela permet de donner des exemples d’usage, tout en testant son code. C’est chouette.

Musique ?

Musique.

Hello doctests

Faire des doctests n’est pas bien compliqué car c’est du copier coller. On fait une session shell avec ce qu’on veut tester, et on copie-colle le tout dans la docstring. Fastoche.

# on copie juste la session de shell tel quel
def ajouter(a, b):
    """
        >>> ajouter(1, 2)
        3
    """
    return a + b
 
# et on demande à Python de parser les doctests. Directement dans votre fichier
# de code. Si, si. Pas de fichier de tests à part.
if __name__ == "__main__":
    import doctest
    doctest.testmod()

On lance ensuite directement notre fichier de code :

python mon_module.py

Et ça n’affiche absolument rien. C’est parce qu’il n’y a pas d’erreur. On peut avoir le topo en demandant un peu de verbosité avec -v :

python mon_module.py -v
Trying:
    ajouter(1, 2)
Expecting:
    3
ok
1 items had no tests:
    __main__
1 items passed all tests:
   1 tests in __main__.ajouter
1 tests in 2 items.
1 passed and 0 failed.
Test passed.

Les doctests marchent purement en se basant sur le formatage texte. Python va prendre la ligne avec >>>, l’exécuter, si la ligne suivante ne contient pas de >>>, il va comparer le résultat de l’exécution de la ligne précédente avec le contenu de la ligne qui la suit.

Ceci passe :

"""
    >>> ajouter(1, 2)
    3
"""

Mais ceci échoue :

"""
    >>> ajouter(1, 2)
    4
"""

Car le résultat AFFICHÉ dans le shell est 3, et non 4.

En cas d’échec, Python vous en dit un peu plus :

python mon_module.py
**********************************************************************
File "mon_module.py", line 6, in __main__.ajouter
Failed example:
    ajouter(1, 2)
Expected:
    4
Got:
    3
**********************************************************************
1 items had failures:
   1 of   1 in __main__.ajouter

Formater ses doctests

Les doctests sont faits pour s’intégrer de manière transparente aux docstrings. On peut donc en mettre autant qu’on veut, au milieu du texte ordinaire de la docstring. Python se base sur les chevrons (>>>) pour savoir quand commence un test, et le saut de ligne pour savoir quand ça se termine. Au delà de ça, le style est libre.

def ajouter(a, b):
    """ Je peux mettre n'importe quoi ici.
 
        Et ici aussi.
 
        Puis intégrer des tests:
 
        >>> ajouter(1, 2)
        3
        >>> ajouter(2, 2)
        4
 
        Et un saut de ligne indique que les tests sont terminés. Mais je peux
        encore en ajouter après si je veux.
 
        >>> ajouter(0, 0)
        0
 
    """
    return a + b

Néanmoins, l’intérêt des doctests est de documenter son code à travers les tests, et donc on adoptera généralement un format tel que :

def ajouter(a, b):
    """ Additionne deux elements.
 
        Exemple :
 
            >>> # on peut mettre des commentaires ici
            >>> ajouter(1, 2) # ou là
            3
            >>> ajouter(2., 2) # fonctionne sur tous les types de nombre
            4.0
 
        La fonction fonctionne en duck typing, et accepte donc tout objet
        qui possède la méthode magique __add__ :
 
            >>> ajouter('a', 'b')
            'ab'
            >>> ajouter([1], [2])
            [1, 2]
    """
    return a + b

Notez comme il est agréable de lire cette docstring : on comprend tout de suite comment utiliser la fonction. En prime, ce sont des tests unitaires qui garantissent que notre fonction va continuer de fonctionner correctement et nous oblige à garder cette doc à jour.

On peut faire des imports dedans ou utiliser temporairement pdb pour debugger. N’importe quel code de shell est valide mais faites attention à ne pas démarrer des boucles infinies comme les event loops des GUI ou lib async.

Voici ce que donnerait l’exemple des articles précédents avec des docstests :

def get(data, index, default=None):
    """ Implémente l'équivalent de dict.get() pour les indexables.
 
        Example :
 
            >>> simple_comme_bonjour = ('pomme', 'banane')
            >>> get(simple_comme_bonjour, 0)
            'pomme'
            >>> get(simple_comme_bonjour, 1000, 'Je laisse la main')
            'Je laisse la main'
    """
    try:
        return data[index]
    except IndexError:
        return default

Problèmes et solutions

Les doctests ne sont pas la Panacée, particulièrement parce que le test se fera sur le résultat AFFICHÉ dans le shell. Cela peut facilement amener à des erreurs.

Déjà, il faut faire attention à la représentation des objets dans le shell Python. La représentation n’est pas forcément la valeur de saisie :

>>> 1.
1.0
>>> "1"
'1'
>>> {"foo": "bar", "une apostrophe : '": "est échapée ainsi qu'un accent"}
{"une apostrophe : '": "est \xc3\xa9chap\xc3\xa9e ainsi qu'un accent", 'foo': 'bar'}

La solution à ce problème est de tester dans le shell les valeurs de retour, et non de le faire de tête. Faites bien gaffe aux espaces qui sont donc significatifs, surtout ceux en fin de ligne. Mon éditeur est configuré pour les virer par défaut, et ça m’a niqué en écrivant l’article :)

Ensuite, il y a des cas où la représentation ne sera pas la même d’un appel à l’autre.

C’est le cas avec les dictionnaires, puisque l’ordre des éléments n’est pas garanti par nature. Ne faites donc pas :

>>> retourne_un_dico()
{'ordre': 'non garanti', 'le': 'resultat'}

Mais plutôt quelque chose qui vous garantit l’affichage :

"""
>>> res = list(retourne_un_dico().items())
>>> res.sort()
[('le', 'resultat'), ('ordre', 'non garanti')]
>>> # ou
>>> retourne_un_dico() == {'ordre': 'non garanti', 'le': 'resultat'}
True
"""

Parfois, on ne peut juste pas garantir l’affichage. Par exemple avec des nombres non prévisibles comme les hash ou les id des objets :

"""
>>> class Test(): pass
>>> repr(Test())
''
"""

7f4687d30fc8 n’est ici pas prévisible. Python met certains cas spéciaux comme celui-ci des flags activables via le commentaire # doctest: +NOM_DU_FLAG.

Par exemple, le flag ELLIPSIS permet de placer ... dans le résultat en guise de joker :

"""
>>> repr(Test()) # doctest: +ELLIPSIS
''
"""

D’autres problèmes similaires peuvent être résolus ainsi. Le flag SKIP permet de sauter un test que vous voulez mettre là, en exemple, mais qui ne doit pas être testé :

"""
>>> # ce test va être ignoré
>>> repr(Test()) # doctest: +SKIP
''
"""

NORMALIZE_WHITESPACE permet de considérer toute séquence de caractères non imprimables comme un espace. 8 tabs ou 4 espaces dans le résultat seront tous considérés comme un espace.

"""
>>> 'ceci est une assez longue ligne divisible' # doctest: +NORMALIZE_WHITESPACE
'ceci    est     une assez longue    ligne divisible'
"""

Les flags sont cumulables, si on les sépare par des virgules dans le commentaire.

Autre astuce, si votre sortie doit contenir un saut de ligne, Python va l’interpréter comme la fin des tests. On peut pallier cela en utilisant <BLANKLINE> :

"""
>>> print('Un saut de ligne\\n')
Un saut de ligne
 
"""

Faites attention aux antislash et autres caractères spéciaux dans vos docstests puisque toute string est parsée deux fois : une fois à l’écriture de la docstring, puis une fois à son exécution. Ici vous voyez que je suis tenu d’échapper mon \n On peut d’ailleurs utiliser les préfixes r (cf: les raw strings) et u sur les docstrings, si un jour vous êtes bloqué par trop d’échappements ou des caractères non ASCII en pagaille, pensez-y.

Un cas particulier est celui des exceptions. LOL, n’est-il pas ?

Pour y répondre, Python décide qu’une expression est levée si il voit Traceback (most recent call last):. Il ignore ensuite tout le texte – qui est donc optionnel et que vous pouvez omettre – jusqu’à ce qu’il rencontre le nom de l’exception levée. À partir de là, il vérifie que le test passe.

Par exemple, si votre exception génère ce traceback :

Traceback (most recent call last):
  File "", line 1, in 
  File "test.py", line 41, in ajouter
    1 / 0
ZeroDivisionError: integer division or modulo by zero

Vous pouvez faire dans votre doctest :

"""
>>> je_leve_une_exception()
Traceback (most recent call last):
ZeroDivisionError: integer division or modulo by zero
"""

Seule la dernière ligne est comparée.

Il est également possible de mettre les doctests dans un fichier texte à part, mais je ne vous le recommande pas. Cela retire l’intérêt principal des doctests : avoir du code exécutable dans la doc. Si on doit avoir un fichier séparé, autant utiliser des tests normaux, bien plus pratiques et complets.

Car il n’y a pas de tear down, setup ou fixtures avec les docstests. Ca reste un outil basique.

Sachez néanmoins que les doctests sont parfaitement compris par pytest, il suffit juste de lui demander de les exécuter avec l’option suivante :

py.test --doctest-modules

Dans ce cas, il n’est pas nécessaire de faire à la fin de chaque fichier contenant des doctests :

if __name__ == "__main__":
    import doctest
    doctest.testmod()

Quand utiliser les doctests ?

Généralement, on utilise un mélange des tests ordinaires (dans notre cas des tests pytest plutôt que unittest) et des doctests.

On utilisera des doctests pour les objets ou les fonctions simples et indépendantes. J’entends par là, des fonctions et des objets qui prennent uniquement des types basiques en paramètres, et qui retournent uniquement ces types basiques en paramètres. Pour les objets, ils doivent avoir peu de méthodes.

Pour tout le reste, on utilisera des tests ordinaires.

Par exemple, si vous avez une fonction comme notre exemple get(), les doctests sont un bon choix. En revanche, si vous avez un objet Server qui est un serveur HTTP, ou une fonction qui prend un objet Server en paramètre, il vaut mieux utiliser les tests ordinaires.

Il est tout à fait possible, et même agréable, de mettre quelques tests simples en doctests qui aident à la documentation, et de faire les tests les plus compliqués via pytest.

Prochaine étape, les mocks. Parti de là, je pourrai vous dire quelles parties de votre programme tester en priorité, et comment. Au début je voulais faire l’inverse, mais finalement, c’est plus pratique.


Télécharger le code de l’article

8 thoughts on “Un gros guide bien gras sur les tests unitaires en Python, partie 4

  • foxmask

    J’ai ouïe dire que pour Django doctest était deprecated alors que j’en avais mis partout dans mes models. Me suis fait violence avec django.test du coup mais c’est en tout cas pratique pour des scripts python tout nu

  • Sam Post author

    Si c’est le cas c’est juste le support des wrappers django, on peut toujours utiliser les doctests manuellement. Mais bon, pour un site Django, pytest-django est de toute façon bien plus pratique que les doctests.

  • kontre

    L’histoire de la comparaison des valeurs affichées est ultra relou quand on veut avoir du code compatible python2 et python3. Il y a pas mal d’objets qui s’affichent différemment, notamment les string unicode (qui ont un ‘u’ devant en python2 et pas en python3), ou alors certaines exceptions qui ont changé. Il y a des moyens de contournement (pour les strings, utiliser print()) mais ça alourdit énormément les exemples. Bref, c’est bien quand ça marche, sinon il faut vite passer sur des tests à part et ne pas s’acharner.

    Un truc à savoir, c’est que les flags genre +ELLIPSIS ou +SKIP ou encore n’apparaissent pas dans les doc sphinx (je ne sais pas pour pydoc et help, j’ai jamais testé). Ca ne pollue pas la doc et c’est un gros plus.

  • foxmask

    @sam : la doc qui annonce sa suppression en 1.8

    doctest
    Django bundles a modified version of the doctest module from the Python standard library (in django.test._doctest) and includes some additional doctest utilities. These utilities are deprecated and will be removed in Django 1.8; doctest suites should be updated to work with the standard library’s doctest module (or converted to unittest-compatible tests).

  • Sam Post author

    Voilà, ce sont les outils spéciaux pour Django qui sont depréciés, mais on peut toujours le faire nous à la mano.

  • Von_Tenia

    Interessant… J’avoue que j’ai aussi tendance a preferer les tests a part (surtout qu’ils sont souvent plus long que mon code). Mais je peux voir l’interet.
    Maintenant si vous prenez les requests j’aimerai bien un article sur Mock et comment bien l’utiliser :).

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.