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}"
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()
Ce que nous faisons ici :
-
Nous commençons par importer le module
unittestet la fonction à tester -
Nous créons une classe
TestFooqui hérite deunittest.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_utilisateurdans laquelle nous exécutons la fonction, puis nous comparons le retour de la fonction avec la chaîne exacte que nous voulons obtenir viaself.assertEqual -
unittest.main()permet d'exécuter les 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
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
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)
À 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
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)
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)
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)
Si je lance les tests sur mon Mac, le premier sera ignoré, mais le deuxième s'exécutera :
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)
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)
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):contientmock_randint, qui représente notre mock passé par le décorateur@patch -
mock_randint.return_value = 6permet de configurer notre faux dé pour qu'il renvoie automatiquement 6 -
resultat = lancer_de()va appelerrandom.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"
À 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 }
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")
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")
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 😎.