Tests unitaires en Python avec unittest

Découvrez comment tester votre code avec le module unittest de Python.

Publié le par Gabriel Trouvé (mis à jour le )

33 minutes

Pendant le développement d'un programme ou d'une application, vous lancez votre script et tout fonctionne parfaitement. Plus tard, vous apportez ne serait-ce qu'une modification mineure à votre programme et plus rien ne fonctionne. À qui cela n'est-il jamais arrivé ? 😅

Même en exécutant son code au fur et à mesure manuellement, avec quelques print au passage, automatiser la vérification de votre code vous fera gagner du temps. Par exemple, vous n'êtes pas à l'abri que l'implémentation d'une fonctionnalité casse une ancienne fonctionnalité.
C'est ici qu'entre en jeu unittest, le framework de la bibliothèque standard de Python. Il offre une structure toute prête pour tester vos applications.

Un peu de vocabulaire

En utilisant unittest, vous allez souvent rencontrer ces termes :

  • Test Case : un cas de test qui vérifie une réponse spécifique pour des données

  • Test Suite : un regroupement de plusieurs Test Cases exécutés ensemble

  • Test Runner : l'exécuteur, c'est lui qui vous fournira le rapport final

Écrire son premier test

Commençons par créer une simple fonction pour saluer des utilisateurs (je sais, j'aurais pu être plus original) :

# foo.py
def saluer(nom):
    return f"Bonjour {nom}"
PYTHON

Nous allons créer un fichier dédié test_foo.py pour vérifier que la fonction se comporte bien comme prévu. Il est important de séparer votre code métier et vos tests en créant des fichiers spécifiques. On les nomme avec le préfixe test_.

# test_foo.py
import unittest
from foo import saluer

class TestFoo(unittest.TestCase):

    def test_saluer_utilisateur(self):
        # On exécute notre fonction
        resultat = saluer("Patrick")

        # On vérifie que le résultat
        self.assertEqual(resultat, "Bonjour Patrick")

if __name__ == '__main__':
    unittest.main()
PYTHON

Ce que nous faisons ici :

  • Nous commençons par importer le module unittest et la fonction à tester

  • Nous créons une classe TestFoo qui hérite de unittest.TestCase, ce qui permet de dire à Python qu'il s'agit d'une classe de tests

  • Nous définissons ensuite une méthode de test test_saluer_utilisateur dans laquelle nous exécutons la fonction, puis nous comparons le retour de la fonction avec la chaîne exacte que nous voulons obtenir via self.assertEqual

  • unittest.main() permet d'exécuter les tests

Exécution des tests

Exécution des tests

On pourrait se passer du bloc if __name__ == '__main__': unittest.main() en bas de chaque fichier de test en utilisant directement le terminal :

python -m unittest test_foo.py
PYTHON
Exécution des tests via le terminal

Exécution des tests via le terminal

Là où c'est encore mieux, c'est que si vous avez plusieurs fichiers de tests, vous pouvez demander à unittest de les exécuter en une seule fois :

python -m unittest
PYTHON

Attention

L'écriture des tests doit être très rigoureuse :

  • Votre classe doit hériter de unittest.TestCase

  • Vos méthodes de test doivent commencer par le mot test_

  • Les fichiers de tests doivent commencer par test_

Maîtriser les assertions

Nous venons d'utiliser assertEqual, une méthode qui fait partie des assertions. Les assertions sont des méthodes qui permettent de vérifier si le résultat obtenu correspond au résultat attendu.

Voici les assertions les plus communes :

import unittest

class TestMesDonnees(unittest.TestCase):

    def test_assertions_classiques(self):
        age = 30
        est_admin = True
        utilisateurs = ["Patrick", "Sebastien"]
        valeur_nulle = None

        # Vérifie l'égalité
        self.assertEqual(age, 30)

        # Vérifie l'inégalité
        self.assertNotEqual(age, 25)

        # Vérifie qu'une condition est vraie ou fausse
        self.assertTrue(est_admin)
        self.assertFalse(age < 18)

        # Vérifie qu'un élément est présent dans une collection (liste, dictionnaire, tuple...)
        self.assertIn("Patrick", utilisateurs)
        self.assertNotIn("Ely", utilisateurs)

        # Vérifie qu'une variable est bien None
        self.assertIsNone(valeur_nulle)

        # Vérifie le type d'une variable
        self.assertIsInstance(age, int)
PYTHON

À noter

Il s'agit des assertions les plus courantes, mais le module unittest regorge d'autres méthodes pour différentes situations !

Tester les erreurs

Parfois, votre code est censé lever des erreurs (pour ne pas dire planter) et ce comportement doit être testé.

Imaginons une fonction de division :

def diviser(a, b):
    if b == 0:
        raise ValueError("La division par zéro est interdite !")
    return a / b
PYTHON

Nous utilisons assertRaises avec un gestionnaire de contexte (context manager) :

class TestMath(unittest.TestCase):
    def test_division_par_zero(self):
        # On s'assure que l'appel à la fonction lève bien une ValueError
        with self.assertRaises(ValueError):
            diviser(10, 0)
PYTHON

Le cycle de vie d'un test : les fixtures

Bien souvent, vos tests auront besoin de données de départ ou d'un environnement spécifique. Vous définissez un contexte une seule fois dans le code et il devient automatiquement disponible dans chaque méthode de test tout en étant recréé à neuf ! 😎

De manière générale, on parle de fixtures.

Pour gérer les fixtures, unittest fournit deux méthodes :

  • setUp() est exécutée avant chaque méthode de test. C'est ici que l'on construit notre fixture

  • tearDown() est exécutée après chaque méthode de test. C'est ici que l'on nettoie notre environnement

import unittest


class TestPlaylistMetal(unittest.TestCase):

    def setUp(self):
        print("\n--- Initialisation de la playlist metal ---")
        # On crée notre fixture : une playlist avant chaque test
        self.playlist = ["Master of Puppets", "Paranoid"]

    def tearDown(self):
        print("--- Nettoyage de la playlist ---")
        # On nettoie la fixture
        self.playlist.clear()

    def test_ajouter_titre(self):
        print("Test de l'ajout")
        self.playlist.append("Le Petit Bonhomme en mousse")
        self.assertIn("Le Petit Bonhomme en mousse", self.playlist)
        self.assertEqual(len(self.playlist), 3)

    def test_supprimer_titre(self):
        print("Test de la suppression")
        self.playlist.remove("Paranoid")
        self.assertNotIn("Paranoid", self.playlist)
        # La longueur est de 1, car le test précédent n'a AUCUN impact ici !
        self.assertEqual(len(self.playlist), 1)
PYTHON
Tests avec fixture

Tests avec fixture

À noter

Chaque test repart avec une self.playlist toute neuve. On voit bien que même si Patrick arrive à ajouter son "Petit Bonhomme en mousse" dans le premier test, il disparaît automatiquement dès le test suivant. Les tests sont totalement isolés les uns des autres.

Ignorer des tests

On peut sauter l'exécution d'un test si une fonctionnalité n'est pas encore terminée ou si le test n'est pas valide sur un système d'exploitation spécifique.

import unittest
import sys

class TestFonctionnalitesAvancees(unittest.TestCase):

    @unittest.skip("La fonctionnalité n'est pas encore développée")
    def test_envoi_email(self):
        pass

    @unittest.skipIf(sys.platform == "win32", "Ne fonctionne pas sous Windows")
    def test_chemin_fichier_unix(self):
        self.assertTrue(True)
PYTHON

Si je lance les tests sur mon Mac, le premier sera ignoré, mais le deuxième s'exécutera :

Tests avec skip

Tests avec skip

Mocker en Python : comment simuler des données avec unittest.mock ?

C'est sans doute la partie qui peut paraître la plus complexe, mais elle est indispensable. Imaginez une application qui fait appel à une API externe : vous ne pouvez pas risquer qu'un test échoue à cause d'une connexion absente, d'une API temporairement indisponible ou tout simplement consommer des crédits à chaque lancement de la suite de tests.

C'est ici que le mock entre en jeu : il permet de simuler une réponse.

Simuler une valeur de retour simple

Imaginez une fonction qui utilise la bibliothèque random, ce qui est difficile à tester de manière prévisible.

import random

def lancer_de():
    return random.randint(1, 6)
PYTHON

Le décorateur @patch permet d'intercepter l'appel à random.randint pour forcer le résultat :

import unittest
from unittest.mock import patch

class TestJeu(unittest.TestCase):

    # On "patch" la fonction randint
    @patch('random.randint')
    def test_lancer_de_gagnant(self, mock_randint):
        # On force notre fausse fonction à toujours renvoyer 6
        mock_randint.return_value = 6

        resultat = lancer_de()

        self.assertEqual(resultat, 6)

        mock_randint.assert_called_once_with(1, 6)
PYTHON

Prenons le temps de regarder ce qui se passe :

  • @patch('random.randint') indique à Python le chemin de la fonction à intercepter. Pendant la durée du test, la vraie fonction est mise de côté et remplacée par un mock

  • def test_lancer_de_gagnant(self, mock_randint): contient mock_randint, qui représente notre mock passé par le décorateur @patch

  • mock_randint.return_value = 6 permet de configurer notre faux dé pour qu'il renvoie automatiquement 6

  • resultat = lancer_de() va appeler random.randint(1, 6) qui sera en réalité notre mock, lequel renverra 6 dans tous les cas

  • mock_randint.assert_called_once_with(1, 6) permet de s'assurer que la fonction a bien été appelée une fois avec les paramètres 1 et 6

Simuler un objet complexe

Prenons un cas assez banal : une fonction qui utilise la bibliothèque requests pour récupérer des informations via une API (fictive dans notre cas). Par exemple, pour récupérer des données météo.

import requests

def obtenir_meteo(ville):
    # Fausse API pour l'exemple
    reponse = requests.get(f"https://api.meteo.com/{ville}")
    # response est en objet de type Response de la bibliothèque requests
    if reponse.status_code == 200:
        data = reponse.json()
        return f"Il fait {data['temperature']}°C à {ville}"
    return "Météo indisponible"
PYTHON

À noter

data = reponse.json() prend la réponse de l'API (au format JSON) et la transforme en un dictionnaire Python. Ici, l'API est fictive, mais le dictionnaire pourrait avoir cette structure :

{
    "ville": "Paris",
    "temperature": 25,
    "conditions": "Ensoleillé",
    "humidite": 40
}
PYTHON

Nous pouvons ensuite récupérer la température : data['temperature'].

Pour tester cela, il faut simuler tout l'objet Response de requests, son attribut status_code et sa méthode json(). Nous allons voir ici la classe Mock(), un objet totalement vide auquel on peut greffer n'importe quel attribut ou n'omporte quelle méthode. Pas contrariant ce Mock() 😁.

Dans notre fichier de test :

from unittest.mock import patch, Mock
import unittest

from main import obtenir_meteo

class TestMeteo(unittest.TestCase):

    @patch('requests.get')
    def test_obtenir_meteo_succes(self, mock_get):
        # 1. On crée un faux objet de réponse
        fausse_reponse = Mock()
        fausse_reponse.status_code = 200
        # On simule le retour de la méthode .json() en renvoyant un dictionnaire
        fausse_reponse.json.return_value = {
            "ville": "Paris",
            "temperature": 25,
            "conditions": "Ensoleillé",
            "humidite": 40
        }

        # 2. On assigne cet objet simulé au retour de notre mock
        mock_get.return_value = fausse_reponse

        # 3. On exécute notre fonction
        resultat = obtenir_meteo("Paris")

        # 4. On vérifie !
        self.assertEqual(resultat, "Il fait 25°C à Paris")
        mock_get.assert_called_once_with("https://api.meteo.com/Paris")
PYTHON

Simuler une erreur avec side_effect

Si l'API tombe en panne, on doit s'assurer que notre code réagit bien. Au lieu d'un return_value, nous utiliserons side_effect pour lever une exception :

import unittest
from unittest.mock import patch
import requests

from main import obtenir_meteo

class TestMeteo(unittest.TestCase):

    @patch('requests.get')
    def test_obtenir_meteo_panne_reseau(self, mock_get):
        # On simule une coupure internet (Levée d'une exception)
        mock_get.side_effect = requests.exceptions.ConnectionError("Pas de réseau")

        # On s'assure que notre code propage bien l'erreur
        with self.assertRaises(requests.exceptions.ConnectionError):
            obtenir_meteo("Londres")
PYTHON

Bien que unittest soit la bibliothèque standard pour les tests, pytest est une bibliothèque tierce très populaire... et nous avons d'ailleurs un article sur cette bibliothèque 😎.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.