Python + doctest : quand la doc devient test

le 30/06/2010 par Arnaud Mazin
Tags: Software Engineering

Introduction

Derrière ce titre abscons se trouvent deux concepts qui mettent en application le principe du KISS dans le langage de programmation Python : écrire de la doc et mettre des tests dans des sources Python, c'est simple avec l'utilisation conjointe des docstrings et du module doctest. Le concept proposé ici est des plus simples : écrire un test unitaire pour un objet présente beaucoup de similitudes avec le fait d'écrire la documentation de ce même objet, en particulier si on y présente des exemples d'utilisation. Ainsi, en faisant d'une pierre deux coups, on gagne du temps et limite les écarts.

Quelques concepts

Syntaxe

À la base, ce langage se veut nativement simple à écrire et à lire à partir du moment où l'on s'accorde sur un postulat : l'indentation n'est pas faite pour faire beau, elle sert vraiment à définir les blocs.

Docstrings

Les docstrings sont des chaines de caractères qui ne servent qu'à une chose : documenter. On peu donc mettre dans son code des docstring pour un module, une classe, une méthode, une fonction… On mettra donc simplement une chaine de caractère en début de fichier, de classe, de méthode, de fonction en utilisant la syntaxe standard des chaines de caractère en Python : entre des guillemets doubles pour une chaine sur une seule ligne, entre 3 guillemets doubles pour une chaine multi-ligne.

Les docstrings ne sont pas des commentaires : autant ces derniers sont complètement ignorés lors de l'analyse syntaxique des sources (La balise dièse "#" délimitant le début d'un commentaire), autant les docstring sont chargés par l'interpréteur Python.

#!/usr/bin/python
# file: plop.py
"This module is used to do some stuff"

def my_routine(a, b):
    """This routine will do some
    very important stuff"""
    return 0

Une fois ces chaines de documentation écrites, l'outil pydoc vous permet de visualiser la doc comme une page de man, en génère des pages HTML ou peut même monter un serveur Web pour héberger la doc de notre magnifique module.

Il est également possible d'invoquer dynamiquement les docstrings de n'importe quel objet :

# python Python 2.6.5 (r265:79063, Apr  1 2010, 05:22:20) [GCC 4.4.3 20100316 (prerelease)] on linux2 Type "help", "copyright", "credits" or "license" for more information.

import plop plop.my_routine.__doc__ 'This routine will do some\n    very important stuff'

Et les tests dans tout ça ?

L'idée contenue dans doctest est qu'il a été défini une syntaxe particulière dans les docstrings de manière à pouvoir y écrire directement des tests. On utilise un triple chevron >>> pour indiquer que l'on écrit un test. Une ligne suivante avec la même indentation et sans chevron est considérée comme le résultat du test. Ce qui donne à peu près ça.

#!/usr/bin/python
# file: plop.py
"This module is used to do some stuff"

def my_routine(a, b):
    """This routine will do some
    very important stuff. Examples:
    >>> my_routine(1, 2)
    3
    """
    return 0

def _test():
    "self-test routine"
    # load the doctest module, part of the std Python API
    import doctest
    # invoke the testmod function that will parse
    # the whole content of the file, looking for
    # docstrings and run all tests they contain
    doctest.testmod()

if __name__ == '__main__':
    _test()

Ici, j'ai écrit un test disant que si j'invoque ma fonction avec comme paramètre 1 et 2, elle est censée me retourner 3.

Pour passer les tests, il suffit désormais d'invoquer le module dans un interpréteur Python.

# python plop.py ********************************************************************** File "plop.py", line 9, in __main__.my_routine Failed example: my_routine(1, 2) Expected: 3 Got: 0 ********************************************************************** 1 items had failures: 1 of   1 in __main__.my_routine ***Test Failed*** 1 failures.

Il ne me reste plus qu'à faire passer le test le plus vite possible. On rentre là dans un processus de TDD classique.

Pour finir, citons la possibilité de mettre en place des tests compliqués, faisant appels à des Monkey Patches si besoin (modification dynamique et à la volée du comportement d'objets, fonctions ou méthodes, bien utile pour en bouchonner le comportement en phase de tests unitaires), nécessitant éventuellement plusieurs lignes d'initialisation, ou générant des exceptions.

"""This routine will do some
    very important stuff. Examples:
    >>> # a test that is supposed to raise an exception
    >>> my_routine(8)
    Traceback (most recent call last):
    ...
    TypeError: my_routine() takes exactly 2 arguments (1 given)
    >>> # a test that needs some kind of initialization
    >>> a = 1
    >>> b = 2
    >>> my_routine(a, b)
    3
    """

Conclusion

L'avantage d'une telle solution est la simplicité. Je peux me garder de maintenir des fichiers contenant mes tests à part, tout est dans les mêmes sources. Un utilisateur de mon module pourra ainsi en regardant sa doc avoir directement accès à des exemples d'utilisation, exemples nécessairement à jour, puisque passant les tests.

Bien entendu, au final, on peut vite arriver à du code qui contient plus de docstrings que d'instructions vraiment opérationnels.

Cette solution de gestion des tests n'est bien entendu qu'une parmi toutes celles disponibles dans le monde Python, (unittest en permier lieu, bien plus proche des librairies de tests unitaires que l'on trouve usuellement), mais sa rapidité de mise en œuvre mérite le détour.