Doctest : vous n'avez aucune excuse pour ne pas écrire des tests unitaires en Python !

Je connaissais l'existence de Doctest depuis longtemps, mais je n'avais jamais pris le temps de vraiment m'y intéresser ni de l'utiliser. De toute façon j'utilisais déjà Pytest qui est plus puissant, donc Doctest ne pouvait pas m'être utile pas vrai ? Spoiler alert : j'avais tort !

Doctest est un framework de test unitaire qui fait partie de la bibliothèque standard de Python : il n'y a donc rien à installer pour l'utiliser. Il fonctionne de manière très différente des framework de test que l'on a l'habitude d'utiliser, et c'est ce qui fait tout son intérêt. Je vous détaille ça tout de suite ! 😁️

Les docstrings

En python, lorsque l'on place une string dans un module, une classe ou une fonction, sans l'assigner à aucune variable, elle devient ce que l'on appelle une docstring. Le texte contenu dans cette chaîne est alors utilisé pour construire la documentation de l'objet.

Voici un exemple de docstring pour que tout le monde voie de quoi on parle :

def add_numbers(a, b):
    """Sums the given numbers.

    :param int a: The first number.
    :param int b: The second number.

    :return: The sum of the given numbers.
    """
    return a + b

Pour ceux qui se demandent, la syntaxe que j'ai utilisée dans la chaîne de documentation ci-dessus est celle de Sphinx, l'outil le plus utilisé pour faire de la doc en Python.

Des tests dans les docstrings

C'est bien joli de parler de docstrings, mais quel rapport avec nos tests ? Eh bien avec Doctest on utilise les docstrings pour y écrire des tests !

On peut se dire que c'est dommage de faire ça puisque du coup on doit sacrifier la doc, non ? TOUT FAUX ! En fait c'est même le contraire : on peut mettre dans la même docstring la doc ET les tests, et de ce fait, on enrichit la doc avec des exemples ! Et le plus beau dans tout ça, c'est que comme il s'agit de tests, les exemples de la doc sont testés et donc toujours à jour !

Comment ça se présente les tests unitaires Doctest ? Reprenons notre fonction add_numbers() en y rajoutant des tests :

def add_numbers(a, b):
    """Sums the given numbers.

    :param int a: The first number.
    :param int b: The second number.

    :return: The sum of the given numbers.

    >>> add_numbers(1, 2)
    3
    >>> add_numbers(50, -8)
    42
    """
    return a + b

La ligne qui commence par trois chevrons (>>>) contient l'instruction à exécuter, et la ligne suivante contient la représentation textuelle du résultat attendu. En fait il s'agit exactement de la syntaxe de la console interactive (REPL) Python. On peut donc tester des trucs directement dans la console Python et les copier / coller comme tests unitaires :

Capture d'écran du REPL Python

Note

NOTE : j'ai parlé ici de placer les tests dans les docstrings des fonctions à tester, mais il faut savoir qu'il est également possible de les placer dans un simple fichier texte à côté du code si on préfère.

Lancer les tests

Pour lancer les tests, c'est hyper simple. Supposons que j'écrive ma fonction add_numbers() dans un fichier nommé operations.py, il suffit alors d'utiliser la commande suivante pour lancer les tests :

$ python -m doctest operations.py

Et là... il ne se passe rien ! 🤨️

Lorsque les tests passent, Doctest ne dit juste rien. Il est cependant possible d'utiliser l'option -v pour le rendre plus bavard :

$ python -m doctest -v operations.py
Trying:
    add_numbers(1, 2)
Expecting:
    3
ok
Trying:
    add_numbers(50, -8)
Expecting:
    42
ok
1 items had no tests:
    operations
1 items passed all tests:
   2 tests in operations.add_numbers
2 tests in 2 items.
2 passed and 0 failed.
Test passed.

Voilà qui est mieux ! Maintenant on sait qu'il a bien exécuté nos tests. 😋️

Et comment cela se passe-t-il lorsqu'il y a une erreur ? Écrivons un test erroné pour voir ce que ça donne :

def add_numbers(a, b):
    """
    >>> add_numbers(1, 2)
    1337
    """
    return a + b

Et maintenant si on exécute ce test, voici le résultat :

$ python -m doctest operations.py
**********************************************************************
File "operations.py", line 3, in operations.add_numbers
Failed example:
    add_numbers(1, 2)
Expected:
    1337
Got:
    3
**********************************************************************
1 items had failures:
   1 of   1 in operations.add_numbers
***Test Failed*** 1 failures.

Bon la sortie manque certes de couleurs, mais le problème est très clairement exposé ! 😋️

Doctest et Sphinx

Comme je le disais en introduction, le second effet kisscool de Doctest, c'est d'ajouter des exemples dans la documentation. Si on reprend la fonction ci-dessus, voici ce que ça rend dans une documentation générée avec Sphinx :

Capture d'écran d'une doc Sphinx contenant un exemple Doctest

Je ne vous en dis pas plus sur Sphinx, il faudrait que j'écrive un article complet sur le sujet pour vous présenter cet outil que j'adore (edit 07/09/2020: c'est par ici), mais vous l'aurez compris, les exemples dans la doc, c'est génial ! 😁️

Doctest et Pytest

J'ai commis pendant longtemps l'erreur de vouloir opposer Doctest et Pytest, ce qui m'a poussé à totalement ignorer le premier au profit du second, alors que les deux outils sont complémentaires. D'ailleurs Pytest est tout à fait capable de lancer des tests écrits pour Doctest, tout en lançant ses propres tests.

Pour lancer des tests Doctest avec Pytest, il suffit d'utiliser son option --doctest-modules. Si on reprend notre exemple de tout à l'heure, cela donne :

$ pytest -v --doctest-modules operations.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.18rc1, pytest-4.6.4, py-1.8.0, pluggy-0.12.0 -- /usr/bin/python
cachedir: .pytest_cache
rootdir: /home/fabien/.../article_doctest
collected 1 item

operations.py::operations.add_numbers PASSED                             [100%]

=========================== 1 passed in 0.01 seconds ===========================

Quels cas d'utilisation pour Doctest ?

Je pense que pour de grosses applications complexes et nécessitant des tests poussés, il vaut mieux utiliser un framework plus puissant, comme Pytest. Cependant, même dans ces grosses applications, Doctest peut être utilisé pour ajouter des exemples à la documentation. Ces exemples étant testés, on pourra s'assurer qu'ils fonctionnent bien et qu'ils restent à jour avec le code.

Par contre, pour de petites applications ou pour les innombrables scripts que l'on écrit rapidement, Doctest est juste parfait ! Surtout qu'il s'agit de cas où on n'aurait probablement pas écrit de tests en temps normal (manque de temps, flemme,... 😜️).

On ne le dira jamais assez : les tests c'est important pour s'assurer qu'un programme fonctionne et continue de fonctionner dans le temps. Avec sa simplicité, Doctest ne demande absolument aucun effort pour être utilisé, et on ne peut donc plus avoir aucune excuse pour ne pas écrire un minimum de tests (oui, même (surtout ?) dans ce petit script de migration fait à l'arrache ! 😜️).

Je ne vous ai montré que son utilisation basique dans cet article, mais sachez que Doctest est bien plus complet qu'il n'y parait, je vous laisse découvrir tout ça par vous-même dans sa documentation officielle :

En tout cas moi j'ai revu mon jugement à son sujet, et je commence à l'utiliser un peu partout, soit comme unique solution de test, soit en complément de Pytest, qui reste malgré tout mon chouchou (on ne se refait pas 😁️).