Fil d'Ariane
Mis à jour le lundi 18 septembre 2017
  • 40 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Ce cours existe en eBook.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Les tests unitaires avec unittest

Connectez-vous ou inscrivez-vous pour bénéficier de toutes les fonctionnalités de ce cours !

Tester ! Tout un monde. Vous allez voir dans ce chapitre comment tester le bon fonctionnement de votre programme et apprendre à le rendre aussi stable que possible au fur et à mesure que vous proposerez de nouvelles améliorations.

Si vous pensez que tester ne sert à rien ou que tester ne se fait que quand tout le développement est fini, je vous encourage vivement à lire ce chapitre, ne serait-ce que pour information.

Pour suivre ce chapitre, vous aurez besoin de savoir comment créer des classes et avoir une idée du fonctionnement de l'héritage.

Pourquoi tester ?

On va parler de tests... mais qu'est-ce qu'on entend par « tester » ?

C'est la première question, et elle est très importante !

Dans ce chapitre, je vais parler de tests (principalement de tests unitaires), qui vérifient que votre code réagit comme il le devrait et qu'il continue à réagir comme il le devrait après de nouvelles améliorations.

Certains développeurs refusent de travailler sur du code qui n'est pas le leur s'il n'a pas de documentation. Pour ce que j'en ai vu, un nombre plus important encore de développeurs refuse de le faire si le code n'a pas de test.

Admettons que vous travaillez sur votre projet qui propose plusieurs fonctions, utilisées par d'autres développeurs ou utilisateurs. Vous pouvez être tout seul sur le projet et ne proposer qu'une dizaine de fonctions, c'est bien suffisant, le plus important c'est que votre code est utilisé par d'autres.

Puis après avoir codé votre dixième fonction, vous commencez à coder votre onzième qui utilise une autre fonction que vous avez déjà développée. Mais vous vous heurtez à un problème : votre nouvelle fonction ne marche pas comme il faut.

Après enquête, vous vous rendez compte que ce n'est pas votre fonction 11 qui pose problème, mais la fonction (1 ou 2) que la fonction 11 appelle. Elle ne répond plus à votre besoin et vous vous dites, naturellement, « je vais la modifier ».

Vous modifiez donc votre fonction 1 ou 2. Votre fonction 11 marche, enfin, sans problème. Vous proposez votre nouvelle version à vos utilisateurs.

Et vous recevez un choeur de protestations : jugez donc ! Ils utilisaient votre fonction 1 ou 2 sans problème, mais avec votre nouvelle version, rien ne marche plus.

Les tests sont une solution possible : pour chaque fonctionnalité de votre programme, il y aura un test et le test va s'assurer que votre programme reste valide même quand vous le modifierez. Ce qui deviendra de plus en plus important au fur et à mesure que votre programme gagnera en fonctionnalités, bien entendu.

Est-ce qu'on doit tester un code quand tout est développé ?

Non ! Si vous pouvez le faire dès le début, dès les premières lignes de code que vous écrivez, c'est mieux. Sachez qu'il peut être assez difficile d'écrire des tests quand votre programme comporte déjà plusieurs centaines de fonctionnalités, il vaut mieux le faire petit à petit.

Il existe aussi plusieurs méthodes de développement, dont le TDD (Test-Driven Development) qui veut que l'on écrive les tests avant d'écrire le code. Je ne rentrerai pas dans le détail ici, mais je vous conseille vivement d'écrire vos tests unitaires même si vous n'avez qu'un tout petit projet avec 4 ou 5 fonctions. Il y a une chance non négligeable que le petit projet devienne grand ; avec des tests à portée de main, vous dormirez plus tranquille.

Est-ce difficile de tester un programme ?

Une fois que vous maîtrisez une des méthodes de test et que vous l'appliquez à votre programme au fur et à mesure, non ce n'est absolument pas difficile. Vous allez voir dans ce chapitre comment utiliser des tests unitaires. Il existe d'autres méthodes de test proposées par Python, mais c'est celle-ci que je trouve, personnellement, la plus rapide à prendre en main ainsi que la plus flexible. Ce chapitre est là pour vous guider par à pas vers la création de vos premiers tests unitaires et même vers la gestion de nombreux tests quand votre projet sera plus grand.

Qui écrit les tests ?

Le développeur, la plupart du temps. Là encore, la méthode de test utilisée peut permettre à d'autres personnes d'écrire les tests, mais les tests unitaires sont souvent écrits par des développeurs (ou des utilisateurs sachant programmer). Comme vous allez le voir, ils ne sont pas très difficiles à écrire, mais vous passerez malgré tout par Python pour ce faire.

Passons à la pratique, la découverte du module unittest !

Premiers exemples de tests unitaires

Le module unittest de la bibliothèque standard de Python inclut le mécanisme des tests unitaires.

Voici la structure que vous rencontrerez le plus souvent :

  • Pour chaque fonctionnalité, un ensemble de fonctions, de classes, de modules, de packages et autre. Tout ce tutoriel est là pour vous montrer comment réaliser cette partie du développement ;

  • Pour chaque fonctionnalité, un test qui vérifie que la fonctionnalité fait bien ce qu'on lui demande. Par exemple, que si une certaine fonction est appelée avec certains paramètres, elle retourne telle valeur.

Nous allons nous intéresser ici à ce second point dans la liste : comment tester une fonctionnalité.

Tester une fonctionnalité existante

Pour commencer, nous allons tester une fonctionnalité déjà existante, proposée dans l'un des modules de Python. Je vais reprendre les exemples de la documentation officielle qui sont assez faciles à comprendre.

Pour cet exemple, nous allons nous intéresser au module random que nous avons déjà utilisé. Nous allons chercher à tester le fonctionnement en particulier de trois fonctions :

  • random.choice : cette fonction retourne un élément au hasard de la séquence précisée en paramètre.

    >>> liste = ["chat", "chien", "renard", "serpent", "cheval", "parapluie"]
    >>> random.choice(liste)
    'renard'
    >>>
  • random.shuffle : cette fonction mélange une liste. La liste d'origine est modifiée.

    >>> liste = [1, 2, 3, 4, 5, 6, 7, 8, 9]
    >>> random.shuffle(liste)
    >>> liste
    [3, 4, 7, 1, 8, 6, 5, 9, 2]
    >>>
  • random.sample : cette fonction prend une séquence et un nombre en paramètres. Elle retourne une nouvelle séquence contenant autant d'éléments que le nombre indiqué, sélectionnés aléatoirement dans la séquence d'origine. Ce n'est pas clair ?

    >>> liste = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
    >>> random.sample(liste, 5)
    ['b', 'a', 'c', 'j', 'e']
    >>> # Ou peut-être que cet exemple sera plus clair
    ... random.sample(range(1000), 10)
    [389, 406, 890, 955, 837, 401, 971, 716, 954, 862]
    >>>
Structure de base d'un test unitaire

Nous le verrons plus loin, un test unitaire peut être constitué de nombreux tests répartis dans plusieurs packages et modules. Pour l'instant, nous n'allons nous intéresser qu'à un test case, la forme la plus simple du test unitaire.

Pour créer un test unitaire, la première chose est de créer une classe héritant de unittest.TestCase :

import random
import unittest

class RandomTest(unittest.TestCase):

On peut définir ensuite un test dans une méthode dont le nom commence par test.

Test de la fonction random.choice

Voyons pour le premier test, le test de la fonction choice :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    def test_choice(self):
        """Test le fonctionnement de la fonction 'random.choice'."""
        liste = list(range(10))
        elt = random.choice(liste)
        # Vérifie que 'elt' est dans 'liste'
        self.assertIn(elt, liste)

Quelques explications s'imposent pour notre méthode de test :

  1. D'abord à la première ligne, on crée une liste de 0 à 9 ;

  2. Ensuite on appelle la fonction random.choice sur notre liste et on récupère le retour ;

  3. Enfin, on vérifie que notre élément retourné par random.choice se trouve bien dans notre liste. On utilise pour ce faire une méthode assertIn et pas le mot clé assert. En fait, unittest.TestCase propose plusieurs méthodes d'assertion que nous utiliserons dans nos tests unitaires. Une assertion lève une exception qui serait considérée par unittest comme une erreur. Nous verrons plus loin comment les erreurs sont gérées.

Si vous exécutez ce code dans votre interpréteur... rien ne se passe ! Vous avez créé une classe mais vous n'avez pas demandé au test de se lancer. Pour ce faire vous pouvez exécuter l'instruction :

unittest.main()

Et vous devriez obtenir quelque chose comme :

.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK

Le retour affiché se décompose en trois parties :

  • D'abord, la première ligne contient un caractère par test exécuté. Les principaux caractères sont un point (".") si le test s'est validé, la lettre F si le test n'a pas obtenu le bon résultat et la lettre E si le test a rencontré une erreur (si une exception a été levée pendant l'exécution de la méthode) ;

  • Ensuite se trouve une ligne récapitulative du nombre de tests exécutés ;

  • Enfin, la dernière ligne récapitule le nombre de réussites ou échecs ou erreurs. Si tout va bien, cette dernière ligne devrait être simplement "OK".

Faisons échouer un test

Modifions notre test pour être sûr de provoquer un échec :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    def test_choice(self):
        """Test le fonctionnement de la fonction 'random.choice'."""
        liste = list(range(10))
        elt = random.choice(liste)
        self.assertIn(elt, ('a', 'b', 'c'))

Et après un appel à unittest.main() :

F
======================================================================
FAIL: test_choice (__main__.RandomTest)
Test le fonctionnement de la fonction 'random.choice'.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "code.py", line 13, in test_choice
    self.assertIn(elt, ('a', 'b', 'c'))
AssertionError: 0 not found in ('a', 'b', 'c')

----------------------------------------------------------------------
Ran 1 test in 0.004s

FAILED (failures=1)

Vous voyez que l'on obtient pas mal d'informations sur les tests qui ne marchent pas. D'abord, notez qu'ici, on parle d'échec (failure) et non pas d'erreur (error). Cela signifie que notre assertion ne s'est pas vérifiée (regardez le traceback) mais que notre test s'est correctement exécuté. Vous pouvez essayer de provoquer une erreur dans la méthode de test aussi, pour voir le résultat.

Le traceback est assez détaillé : il donne la ligne de l'erreur avec les appels successifs, si on a besoin de remonter la piste de l'erreur. Le message d'erreur lui-même donne des informations plus précises sur pourquoi le test a échoué (0 not found in ('a', 'b', 'c')).

Test de la fonction random.shuffle

Intéressons-nous maintenant à la fonction random.shuffle. Souvenez-vous, elle prend une liste en paramètre et mélange cette liste aléatoirement.

En vous inspirant du premier exemple, essayez d'écrire la méthode de test correspondante. Il vous faut réfléchir à comment vérifier qu'une liste, après avoir été mélangée, correspond à une liste d'éléments de 0 à </italique>9</italique>.

Je vous conseille d'utiliser cette fois la méthode d'assertion assertEqual qui prend deux arguments en paramètre et vérifie le test si les arguments sont identiques. Vous trouverez une liste des méthodes d'assertion les plus communes plus bas.

À vous de jouer !

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    # Autres méthodes de test
    def test_shuffle(self):
        """Test le fonctionnement de la fonction 'random.shuffle'."""
        liste = list(range(10))
        random.shuffle(liste)
        liste.sort()
        self.assertEqual(liste, list(range(10)))

Comme vous le voyez, on appelle la fonction random.shuffle avant de trier de nouveau notre liste. Une fois la liste triée de nouveau, elle devra être identique à notre liste d'origine (list(range(10))).

Ici, nous avons utilisé la méthode assertEqual qui sera sans doute celle que vous utiliserez le plus souvent. Nous verrons un peu plus loin une liste des méthodes d'assertion proposées par unittest.TestCase.

Test de la fonction random.sample

Enfin, écrivons notre méthode de test de la fonction random.sample. Souvenez-vous, cette fonction prend deux paramètres : une séquence et un nombre K. Elle retourne une liste contenant K éléments sélectionnés aléatoirement dans notre séquence de base.

Voyons une première approche :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    # Autres méthodes de test
    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        liste = list(range(10))
        extrait = random.sample(liste, 5)
        for element in extrait:
            self.assertIn(element, liste)

Jusqu'ici ce n'est pas bien différent de ce que nous avons fait un peu plus haut.

Avez-vous essayé random.sample en précisant un nombre K plus élevé que la taille de la séquence ?

>>> liste = list(range(10))
>>> random.sample(liste, 20)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\python34\lib\random.py", line 313, in sample
    raise ValueError("Sample larger than population")
ValueError: Sample larger than population
>>>

Citation : PEP 20

Errors should never pass silently.

Ce comportement est attendu et souhaitable. Autant le tester également :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    # Autres méthodes de test
    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        liste = list(range(10))
        extrait = random.sample(liste, 5)
        for element in extrait:
            self.assertIn(element, liste)

        self.assertRaises(ValueError, random.sample, liste, 20)

La dernière ligne mérite quelques explications. On utilise encore une méthode d'assertion assert* (cette fois, assertRaises).

On peut utiliser cette méthode de deux façons :

  • Soit, comme on vient de le faire, en précisant d'abord le type de l'exception qui doit être levée, puis la fonction qui doit être appelée (la référence, sans parenthèses) et enfin les paramètres attendus par la fonction ;

  • Soit en utilisant un context manager (gestionnaire de contexte) qui rend le code plus facile à lire.

Nous avons vu un context manager au moment des fichiers. Rappelez-vous, c'est le bloc d'instructions qui commence par le mot clé with.

Voyons comment écrire notre test avec un context manager.

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    # Autres méthodes de test
    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        liste = list(range(10))
        extrait = random.sample(liste, 5)
        for element in extrait:
            self.assertIn(element, liste)

        with self.assertRaises(ValueError):
            random.sample(liste, 20)

Comme vous le voyez, cette seconde syntaxe est plus lisible :

  1. On appelle un nouveau context manager grâce au mot-clé with ouvert sur le retour de la méthode assertRaises. Cette fois, on ne passe en paramètre de cette méthode que le type de notre exception ;

  2. À l'intérieur de notre bloc se trouve la ligne qui doit lever l'exception ValueError. Si le bloc dans le context manager lève bien l'exception, alors le test passe. Sinon il ne passe pas.

Cette seconde syntaxe est plus lisible, à mon sens, mais je vous montre les deux car vous pourriez trouver la première au cours de vos lectures d'autres codes.

Initialisation des tests

Vous l'avez peut-être remarqué, toutes nos méthodes de test commencent par cette ligne de code :

liste = list(range(10))

Il existe un moyen pour éviter de répéter cette ligne à chaque fois. Nos méthodes de test partagent un point commun : elles sont définies dans la même classe. Autant en profiter.

unittest.TestCase nous propose une méthode qui est appelée avant chaque méthode de test. Il serait mieux que la création de notre liste (de 0 à 9) se trouve dans cette méthode.

Son nom est setUp. Créez-la dans votre classe :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    def setUp(self):
        """Initialisation des tests."""
        self.liste = list(range(10))

Comme vous le voyez, on écrit directement notre liste en attribut d'instance de notre test. Cela veut dire qu'il va falloir modifier nos méthodes de test pour qu'elles l'utilisent :

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    # Autres méthodes de test
    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        extrait = random.sample(self.liste, 5)
        for element in extrait:
            self.assertIn(element, self.liste)

        with self.assertRaises(ValueError):
            random.sample(self.liste, 20)

Au lieu de créer la liste, on utilise l'attribut d'instance créé dans la méthode setUp. Il existe également une méthode tearDown qui est appelée après chaque test.

Récapitulatif complet du code de test

Voici le code complet de notre test case et de nos trois méthodes de test.

import random
import unittest

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    def setUp(self):
        """Initialisation des tests."""
        self.liste = list(range(10))

    def test_choice(self):
        """Test le fonctionnement de la fonction 'random.choice'."""
        elt = random.choice(self.liste)
        self.assertIn(elt, self.liste)

    def test_shuffle(self):
        """Test le fonctionnement de la fonction 'random.shuffle'."""
        random.shuffle(self.liste)
        self.liste.sort()
        self.assertEqual(self.liste, list(range(10)))

    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        extrait = random.sample(self.liste, 5)
        for element in extrait:
            self.assertIn(element, self.liste)

        with self.assertRaises(ValueError):
            random.sample(self.liste, 20)

Souvenez-vous, pour tester le code, vous pouvez ajouter l'instruction unittest.main() à la fin de votre module. Nous verrons un peu plus loin un autre moyen, plus simple, pour tester un ou plusieurs modules.

Les principales méthodes d'assertion

Je vous propose un petit tableau listant les méthodes d'assertion les plus courantes.

Méthode

Explications

assertEqual(a, b)

a == b

assertNotEqual(a, b)

a != b

assertTrue(x)

x is True

assertFalse(x)

x is False

assertIs(a, b)

a is b

assertIsNot(a, b)

a is not b

assertIsNone(x)

x is None

assertIsNotNone(x)

x is not None

assertIn(a, b)

a in b

assertNotIn(a, b)

a not in b

assertIsInstance(a, b)

isinstance(a, b)

assertNotIsInstance(a, b)

not isinstance(a, b)

assertRaises(exception, fonction, *args, **kwargs) 

Vérifie que la fonction lève l'exception attendue.

Pour une liste complète, consultez la documentation officielle du module unittest.

Nous allons nous intéresser à présent à la découverte automatique des tests par Python.

La découverte automatique des tests

Lancer les tests avec unittest.main() peut s'avérer pratique, mais généralement on fera appel à la découverte automatique des tests. Cette fonctionnalité permet de rechercher tous les tests unitaires contenus dans un package et de les exécuter.

Lancement de tests unitaires depuis un répertoire

Pour commencer, nous allons essayer de lancer les tests unitaires que nous avons créés auparavant depuis un répertoire.

  • Créez un répertoire où vous mettez généralement votre code Python. Pour moi, ce répertoire s'appelle pytest et se trouve dans Mes Documents ;

  • Ouvrez la console. Sous Windows, cliquez sur Exécuter... dans le menu démarrer (ou tapez Windows + R) et entrez cmd ;

  • Déplacez-vous dans le répertoire que vous avez créé :

    cd pytest

Une fois dans le bon dossier, créez le fichier test_random.py et collez le code que nous avons vu plus haut :

import random
import unittest

class RandomTest(unittest.TestCase):

    """Test case utilisé pour tester les fonctions du module 'random'."""

    def setUp(self):
        """Initialisation des tests."""
        self.liste = list(range(10))

    def test_choice(self):
        """Test le fonctionnement de la fonction 'random.choice'."""
        elt = random.choice(self.liste)
        self.assertIn(elt, self.liste)

    def test_shuffle(self):
        """Test le fonctionnement de la fonction 'random.shuffle'."""
        random.shuffle(self.liste)
        self.liste.sort()
        self.assertEqual(self.liste, list(range(10)))

    def test_sample(self):
        """Test le fonctionnement de la fonction 'random.sample'."""
        extrait = random.sample(self.liste, 5)
        for element in extrait:
            self.assertIn(element, self.liste)

        with self.assertRaises(ValueError):
            random.sample(self.liste, 20)

Sauvegardez ce fichier et revenez dans la console.

Vous devez maintenant exécuter Python avec l'option -m unittest. Sous Windows vous aurez sûrement une commande comme :

c:\python34\python.exe -m unittest

Sous Linux vous aurez probablement :

python3.4 -m unittest

Si tout se passe bien vous devriez voir les tests s'exécuter :

...
----------------------------------------------------------------------
Ran 3 tests in 0.007s

OK

L'option -m permet d'exécuter un module spécifique (ici unittest). Quand appelé directement depuis Python, unittest cherche les tests unitaires présents dans le dossier courant. Vous pouvez aussi lui donner un chemin de test à exécuter, par exemple test_random.RandomTest.test_shuffle :

  1. test_random est le nom du module (le nom du fichier sans l'extension) ;

  2. RandomTest est le nom de la classe dans notre module ;

  3. test_shuffle est le nom de notre méthode à exécuter.

c:\python34\python.exe -m unittest test_random.RandomTest.test_shuffle
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

Vos tests unitaires doivent être indépendants, c'est-à-dire qu'on peut les exécuter tout seul (comme on vient de le faire) ou en groupe (comme on l'a fait plus tôt). En bref, ils ne doivent pas dépendre d'autres tests pour s'exécuter.

Structure d'un projet avec ses tests

Nous allons ici regarder un projet de taille respectable, CherryPy, qui propose un framework léger pour créer un serveur web. Je vous conseille d'ailleurs de jeter un oeil à ce projet si vous avez le temps.

Si vous téléchargez et décompressez les sources, vous verrez un dossier cherrypy-version. Si vous entrez dedans, vous pouvez lancer les tests unitaires en faisant :

python -m unittest

Il peut être nécessaire d'installer le package au préalable (exécutez la commande python setup.py install pour ce faire).

Si Python trouve les tests unitaires du projet, c'est qu'il explore les répertoires du projet. Il y a notamment le répertoire cherrypy qui contient l'ensemble des sources. Dans ce répertoire se trouve le sous-répertoire test et dans ce sous-répertoire se trouvent les tests de la bibliothèque.

Je ne rentrerai pas dans le détail ici, mais ce qu'il faut comprendre, c'est que la commande python -m unittest explore récursivement les packages et modules à la recherche de tests. Tous les packages sont explorés, mais les modules (comme les méthodes de test) doivent commencer par test.

Généralement, vous trouverez une certaine fonctionnalité (disons dans cherrypy/fonctionnalite.py) et le test de cette fonctionnalité dans un module spécifique (cherrypy/test/test_fonctionnalite.py). Le découpage du dossier test sera souvent le même que le découpage de vos sources (c'est plus une convention qu'une obligation).

Voilà pour ce tour d'horizon des tests unitaires. Là encore, si vous voulez en apprendre plus, rendez-vous sur la documentation officielle du module unittest.

En résumé

  • on peut tester nos applications grâce à plusieurs modules sous Python, les tests unitaires étant supportés par le module unittest ;

  • pour créer un test unitaire, il faut créer une classe qui hérite de unittest.TestCase. Les méthodes de test ont un nom commençant par test ;

  • La commande python -m unittest permet la découverte automatique des tests dans le répertoire courant.

Exemple de certificat de réussite
Exemple de certificat de réussite