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


La partie précédente vous a donné une vague idée de ce qu’étaient les tests unittaires, à quoi ça servait, et quelle forme ça avait.

Dans cette partie, nous allons aborder comment on rédige des tests unitaires avec la lib de standard de Python.

En effet, bien qu’on puisse se contenter de faire des assert et attendre que ça plante, ce n’est pas un moyen très efficace de faire ses tests. Des bibliothèques existent donc pour rendre le test plus puissant. En Python, c’est le module unittest qui s’en charge.

Article long, musique, tout ça…

Le test le plus simple

Pour l’exercice nous allons utiliser une fonction à tester qui soit un peu plus réaliste. Par exemple, les dictionnaires ont une méthode get() qui permet de récupérer une élément du dictionnaire. Si celui-ci n’existe pas, une valeur par défaut est retournée :

>>> simple_comme_bonjour = {"pomme": "banane", "geographie": "litterature"}
>>> simple_comme_bonjour.get("pomme", "je laisse la main")
"banane"
>>> simple_comme_bonjour.get("kamoulox", "je laisse la main")
"je laisse la main"

Une telle fonction n’existe pas pour les itérables, nous allons donc en créer une :

def get(lst, index, default=None):
    """
        Retourne l'élément de `lst` situé à `index`.
 
        Si aucun élément ne se trouve à `index`,
        retourne la valeur par défaut.
    """
    try:
        return lst[index]
    except IndexError:
        return default

Ça s’utilise ainsi :

>>> simple_comme_bonjour = ('pomme', 'banane')
>>> get(simple_comme_bonjour, 0, "je laisse la main")
'pomme'
>>> get(simple_comme_bonjour, 1000, "je laisse la main")
'je laisse la main'

Afin de sécuriser les futures améliorations de cette fonction, nous allons lui adjoindre des tests unitaires. Utiliser le module unittest est beaucoup plus verbeux que faire des assert, il suppose de faire une classe qui va regrouper tous les tests qu’on veut faire :

import unittest
 
# Le code à tester doit être importable. On
# verra dans une autre partie comment organiser
# son projet pour cela.
from mon_module import get
 
# Cette classe est un groupe de tests. Son nom DOIT commencer
# par 'Test' et la classe DOIT hériter de unittest.TestCase.
class TestFonctionGet(unittest.TestCase):
 
    # Chaque méthode dont le nom commence par 'test_'
    # est un test.
    def test_get_element(self):
 
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
 
        # Le test le plus simple est un test d'égalité. On se
        # sert de la méthode assertEqual pour dire que l'on
        # s'attend à ce que les deux éléments soient égaux. Sinon
        # le test échoue.
        self.assertEqual(element, 'pomme')
 
# Ceci lance le test si on exécute le script
# directement.
if __name__ == '__main__':
    unittest.main()

On met tout ça dans un fichier nommé “test_quelquechose”, et on l’exécute :

$ python test_get.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
 
OK

Cela signifie qu’un test a été exécuté (il y a un point affiché par test). Il n’y a eu aucune erreur.

Ajoutons un test pour essayer le cas où l’élément n’existe pas :

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    # Il faut choisir un nom explicite pour chaque méthode de test
    # car ça aide à débugger.
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')

La sortie nous montre maintenant deux tests passés sans erreur :

$ python test_get.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
 
OK

Erreur durant le test

Il y a deux types d’erreur : une erreur logique (le code plante) ou un test qui échoue.

Commençons par le premier cas. Je rajoute une erreur à la noix :

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    # Ce code ne peut pas marcher car il n'y a pas 1000
    # éléments dans mon tuple.
    def test_avec_error(self):
        simple_comme_bonjour = ('pomme', 'banane')
        simple_comme_bonjour[1000]

Et zou :

$ python test_get.py
E..
======================================================================
ERROR: test_avec_error (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 40, in test_avec_error
    simple_comme_bonjour[1000]
IndexError: tuple index out of range
 
----------------------------------------------------------------------
Ran 3 tests in 0.001s
 
FAILED (errors=1)

Cette fois Python lance bien 3 tests, mais un génère une erreur. Il me signale laquelle par un “E” et donne la stacktrace qui permet de débugger le problème.

Vous voyez ici le premier intérêt d’utiliser un outil fait pour les tests plutôt que des assert à la main : Python n’a pas arrêté au premier plantage. Tous les tests ont été exécutés. Cela permet de savoir précisément quels tests parmi tous ceux que vous avez, sont touchés, et lesquels passent.

Retirons l’erreur logique, et ajoutons un un test qui échoue. Un test qui échoue c’est quand une méthode assertQuelquechose s’aperçoit que les valeurs ne correspondent pas à ce que le test voudrait.

class TestFonctionGet(unittest.TestCase):
 
    def test_get_element(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    def test_avec_echec(self):
        simple_comme_bonjour = ('pomme', 'banane')
        element = get(simple_comme_bonjour, 1000, 'Je laisse la main')
        # Ici j'ajoute ARTIFICIELLEMENT une erreur, mais on est bien d'accord
        # que normalement, si ça échoue ici, c'est que votre code ne se comporte
        # pas comme prévu. Personne ne nique ses tests volontairement, sauf
        # les rédacteurs de tutos et les étudiants en histoire de l'art.
        self.assertEqual(element, 'Je tres clair, Luc')
 
        # element ne sera pas égal à "Je tres clair, Luc", il sera égal à
        # 'Je laisse la main'. assertEqual va s'en rendre compte et va
        # déclarer que le test a échoué, puisque qu'elle vérifie l'égalité.

Et voici là nouvelle sortie :

$ python test_get.py
F..
======================================================================
FAIL: test_avec_echec (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 45, in test_avec_echec
    self.assertEqual(element, 'Je tres clair, Luc')
AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc
 
 
----------------------------------------------------------------------
Ran 3 tests in 0.002s
 
FAILED (failures=1)

L’échec est noté avec un F (pour Fail), et on vous donne le nom du test qui a échoué (c’est pour ça que je vous recommande de choisir un bon nom pour chaque test).

Là ça devient beaucoup plus intéressant qu’avec un assert à la main car vous voyez que non seulement on vous dit où est l’erreur, non seulement Python ne plante pas à la première erreur, mais en plus vous avez des informations supplémentaires.

Ici, cette information est :

AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc

On vous dit très explicitement que c’est un échec sur une égalité, et voici les deux valeurs testées. Cela vous permet de rapidement identifier ce qui a merdé.

Il existe de nombreuses méthodes assertTruc, et chacune d’elles produit des informations différentes en cas d’échec.

Ah, certes

Il y a pas mal de méthodes assertBidule. En rédigeant ce tuto, sous Python 3, j’ai fait ça :

>>> [print (x) for x in dir(self) if x.startswith('assert')]
assertAlmostEqual
assertAlmostEquals
assertCountEqual
assertDictContainsSubset
assertDictEqual
assertEqual
assertEquals
assertFalse
assertGreater
assertGreaterEqual
assertIn
assertIs
assertIsInstance
assertIsNone
assertIsNot
assertIsNotNone
assertLess
assertLessEqual
assertListEqual
assertMultiLineEqual
assertNotAlmostEqual
assertNotAlmostEquals
assertNotEqual
assertNotEquals
assertNotIn
assertNotIsInstance
assertNotRegex
assertRaises
assertRaisesRegex
assertRaisesRegexp
assertRegex
assertRegexpMatches
assertSequenceEqual
assertSetEqual
assertTrue
assertTupleEqual
assertWarns
assertWarnsRegex

Selon votre installation de Python, vous en aurez plus ou moins de nombreuses, et je vous invite à vous taper la doc pour voir tout ce qui s’offre à vous.

Voici quelques exemples des possibilités qui s’offrent à vous :

assertAlmostEqual

Va vérifier qu’un nombre est presque égal à un autre, à un arrondi près.

assertDictContainsSubset

Va vérifier que toutes les paires clé/valeur d’un dico sont contenues dans un autre.

assertRaises

Va vérifier que la fonction va lever une exception.

assertRegex

Va vérifier que la chaîne est validée par la regex donnée.

Setup et TearDown

Comme vous avez pu le constater dans notre exemple, à chaque test on recrée le tuple simple_comme_bonjour = ('pomme', 'banane'). Ce n’est pas très pratique. D’autant plus qu’on pourrait avoir des choses bien plus compliquées comme la connexion à une base de données ou une génération de données aléatoires.

Les méthodes setUp et tearDown sont là pour y pallier. Elles permettent respectivement de lancer un code avant chaque test et après chaque test.

class TestFonctionGet(unittest.TestCase):
 
    # Cette méthode sera appelée avant chaque test.
    def setUp(self):
        self.simple_comme_bonjour = ('pomme', 'banane')
 
    # Cette méthode sera appelée après chaque test.
    def tearDown(self):
        print('Nettoyage !')
 
    def test_get_element(self):
        # plus besoin de créer le tuple ici
        element = get(self.simple_comme_bonjour, 0)
        self.assertEqual(element, 'pomme')
 
    def test_element_manquant(self):
        element = get(self.simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je laisse la main')
 
    def test_avec_echec(self):
        element = get(self.simple_comme_bonjour, 1000, 'Je laisse la main')
        self.assertEqual(element, 'Je tres clair, Luc')
$ python test_get.py
Nettoyage !
FNettoyage !
.Nettoyage !
.
======================================================================
FAIL: test_avec_echec (__main__.TestFonctionGet)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_get.py", line 37, in test_avec_echec
    self.assertEqual(element, 'Je tres clair, Luc')
AssertionError: 'Je laisse la main' != 'Je tres clair, Luc'
- Je laisse la main
+ Je tres clair, Luc
 
 
----------------------------------------------------------------------
Ran 3 tests in 0.001s
 
FAILED (failures=1)

Vous voyez qu’il n’y a plus besoin de créer le tuple 3 fois manuellement, c’est fait pour vous. De plus, la chaîne “Nettoyage !” est bien affichée 3 fois, malgré l’échec d’un test. En effet, ces méthodes sont toujours appelées, même en cas d’erreur. Cela permet de créer une environnement propre pour les tests, ou de nettoyer derrière (fermer un fichier, une connexion, etc).

Lancer plusieurs modules de test

Quand vous allez avoir plein de tests, vous n’allez pas tout mettre dans une classe, mais faire plein de fichiers avec des tests par sujet. Parfois vous ne lancerez qu’un fichier de test. Parfois vous voudrez tout lancer d’un coup.

Pour ce faire, assurez vous que vos modules de tests sont importables depuis le dossier où vous êtes. Tout doit être dans le PYTHON_PATH et les dossiers doivent contenir des fichiers __init__.py.

Ensuit, il suffit de lancer la commande :

python -m unittest discover

Python trouvera tous les fichiers de tests pour vous automatiquement, pourvu qu’ils soient nommés test_quelquechose.py.

La ptit’ clusion

Vous voyez maintenant comment utiliser un outil pour rédiger des tests, mais probablement pas COMMENT rédiger un test, ni QUAND et POUR QUOI. C’est tout à fait normal.

Dans les premières parties, je vais faire un tour de tous les outils de tests à votre disposition. Puis, dans les parties suivantes, je ferai un topo sur les questions existentielles du genre “ok mais je teste quel code ?” ou “à quel moment je mets un test” ou “pourquoi c’est moi qui écrit les tests et pas bob cet enculé ?”.

Bref, pour le moment, vous nagez un peu dans le brouillard, et il ne faut pas s’inquiéter. Le dossier sur les tests sera long et comportera surement 8 parties ou plus. Donc on se détend, et on profite du voyage.

Prochaine partie, pytest.


Télécharger le code de l’article

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

  • Bagouze

    Merci pour cet article, la classe, comme d’hab’.
    Est-ce que votre dossier va traiter des test Django un ‘ment donné ?

  • Gontran

    Quand je fais des tests unitaires sous django, je dois souvent populationner ma db avant un test (bricoler des fake user, ou autre). Du coup:

    from foo.models import Foo
     
    # de tête, donc peut-être des erreurs
    class FooTest(unittest.TestCase):
     
      stuff = None
     
    def setUp(self):
      self.stuff = Foo(use='sextoy', location='butt')
      self.stuff.save()
     
    def tearDown(self):
      self.stuff.delete()

    Mais j’ai un peu l’impression de taper 10 fois plus que nécessaire dans ma db (create + delete à chaque test).

    Je suis dans le bon, où on peut trouver une methode plus sexy pour faire ça ?

  • OKso

    Sinon, avec py.test, on se fait pas chier avec toutes ces classes à la con et ces “self.assertEqual(…)”. Des fonctions plates et le mot-clef ‘assert’ sont beaucoup plus simples à utiliser, et du coup ça motive à les faire.

    Je m’immagine pas devoir expliquer toute la POO pour pouvoir expliquer comment créer un bête test unitaire à un pote…

  • kontre

    Clair que py.test (ou nose) sont plus simple à utiliser. Unittest a pour lui d’être inclus dans python de base, et de bien montrer les dessous des tests unitaires. Les assertEqual sont plus faciles à expliquer que la magie de py.test pour retrouver les valeurs de variables dans assert a == b.
    Donc partir sur unittest ça me parait logique, mais j’espère que Sam a prévu de présenter les principaux frameworks dans la suite.
    Y’a aussi les doctests…

  • Sam Post author

    Les prochaines parties traiteront py.test, les docstring, les tests django et aussi, même si c’est pas le sujet, les tests end to end et les tests fonctionnels. Y a du taff, je vous dis pas.

  • Sam Post author

    @Gontran : Non, c’est normal. Tu peux accélérer les tests en mettant ta base de données en mémoire vive, ou alors utiliser py.test qui a des setup/teardown plus modulaire avec pytest-django.

  • gontran

    @Sam : Ok. J’avais cherché pendant pas mal de temps un moyen de faire ça au niveau de la classe, plutôt qu’au niveau des méthodes.

    Ca me parait quand même plus logique dans un contexte objet.

  • Samson

    Bien que l’on puisse voir en ligne cette partie 2 dans “readability.com”, il est impossible de la récupérer en “epub”. On récupère invariablement la partie 1 au lieu de la 2. Est-ce à cause de la virgule dans le titre qu’il confond les deux parties ?? En tout cas les deux parties de “Comprendre les décorateurs Python pas à pas” : (partie 1) et (partie 2) fonctionnent très bien. On peut toutes les deux les récupérer en epub.

  • Sam Post author

    C’est balot :) Mais comme c’est un service externe, on va pas se casser la tête dessus.

  • Samson

    C’est balot :) Mais comme c’est un service externe, on va pas se casser la tête dessus.

    Je comprends. Mais le pire ou le mieux, c’est qu’hier dans un ultime essais, cela à fonctionné. Alors que j’essayé depuis plusieurs jours : oui quand je suis obstiné, je le suis très bêtement. J’ai même cru à une intervention magique voir même divine de votre part (j’attendais votre confirmation)… Et aujourd’hui ça ne fonctionne plus ; je m’en fous je l’ai maintenant… Bon, cela reste tout aussi magique ; à moins qu’à mon insu je sois victime de puissante substances douteuses !!!

    Merci pour votre réponse et surtout merci pour votre site.

Leave a comment

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

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