Je pourrais reprendre l'introduction de mon article sur unittest. D'ailleurs, si vous l'avez lu, vous savez à quel point les tests sont indispensables pour garantir la stabilité de votre code.
Même si unittest fait très bien le travail, la communauté Python a largement adopté la bibliothèque pytest.
Dans ce guide, nous allons voir comment tester son code, les fixtures et les mocks.
Écrire son premier test
Commençons par installer pytest car, en effet, il s'agit d'une bibliothèque tierce.
pip install pytest
Pour cet exemple, nous allons créer une fonction très simple dans foo.py :
# foo.py def saluer(nom): return f"Bonjour {nom}"
Si vous utilisez unittest, il aurait fallu importer le module, créer une classe héritant de TestCase, puis définir une méthode spécifique. Avec pytest, pas besoin de classe, on peut faire cela de manière plus directe dans test_foo.py :
from foo import saluer def test_saluer(): resultat = saluer("Patrick") assert resultat == "Bonjour Patrick"
À noter
Il n'y a même pas besoin d'importer pytest. Contrairement à unittest, où l'import est obligatoire pour hériter de la classe TestCase, pytest sert d'exécuteur. Tant que nous n'utilisons pas de fonctionnalités de la bibliothèque, le simple fait d'utiliser l'instruction native assert est suffisant.
Pour exécuter le test, ouvrez votre terminal et tapez :
pytest
pytest va automatiquement chercher, dans votre dossier, les fichiers commençant par test_ et exécuter les fonctions commençant par test_.
Le mot-clé assert natif
Dans l'exemple précédent, nous avons utilisé le mot-clé assert.
Qu'est-ce que assert en Python ?
En dehors de pytest, il est tout à fait possible d'utiliser assert pour vérifier des invariants pendant le développement. C'est un outil intégré à Python qui permet d'exprimer des conditions qui doivent toujours être vraies dans votre code. Si elles ne le sont pas, c'est qu'il y a un bug.
À noter
Un invariant est une promesse faite sur le code : une condition qui doit toujours être vraie si votre logique est correcte. L'instruction évalue une condition et, si elle est vraie, le programme continue normalement ; sinon, Python lève une AssertionError et arrête le script.
def appliquer_reduction(prix, reduction): # raise : validation des entrées — toujours actif if prix < 0: raise ValueError("Le prix ne peut pas être négatif.") if reduction < 0: raise ValueError("La réduction ne peut pas être négative.") nouveau_prix = prix - reduction # assert : invariant interne — désactivable avec -O # Si on arrive ici, les deux valeurs sont ≥ 0, donc c'est garanti. # Sert juste de filet de sécurité pendant le dev. assert nouveau_prix <= prix, "Anomalie logique : le prix a augmenté !" return nouveau_prix
Attention
Si vous exécutez votre script en mode optimisé (python -O mon_script.py), les assert seront ignorés. C'est pourquoi on ne les utilise pas pour valider les entrées des utilisateurs. C'est pour cette raison que raise est indispensable et qu' assert sert de garde-fou pendant le développement.
pytest fonctionne avec les assert pour les tests. Pas besoin d'utiliser des self.assertEqual(), self.assertTrue() ou self.assertIn() comme avec unittest ; il suffit d'écrire du code Python classique.
pytest et assert
Comme nous venons de le voir, en Python standard, en cas d'échec, un assert se contente de faire crasher le script. Mais quand un assert échoue avec pytest, ce dernier intercepte l'exception et réalise une introspection en vous montrant exactement ce qui diffère entre les deux valeurs comparées.
En effet, dans la console, pytest affichera un message très clair pour vous aider à déboguer. Prenons une fonction qui renvoie le prénom Patrick et une fonction de test test_prenom :
Exemple avec un test simple
Tester les erreurs (exceptions) attendues
Il est parfois nécessaire de vérifier qu'une fonction lève bien une erreur quand on lui donne une mauvaise valeur. Vous le voyez venir ? L'exemple le plus simple : la division par zéro ! Avec pytest, on utilise un "context manager" avec pytest.raises. L'import de pytest devient donc nécessaire :
# main.py def diviser(a, b): if b == 0: raise ValueError("La division par zéro est interdite !") return a / b # test_diviser.py import pytest from main import diviser def test_division_par_zero(): # On s'assure que l'appel à la fonction lève bien une ValueError # On peut aussi vérifier que le message d'erreur est correct grâce à l'argument 'match' with pytest.raises(ValueError, match="La division par zéro est interdite !"): diviser(10, 0)
Les fixtures : LE super-pouvoir de pytest
Qu'est-ce qu'une fixture pytest ?
Une fixture est une fonction décorée qui fournit des données ou un état à vos tests. pytest utilise un fichier conftest.py, où l'on peut définir des fixtures pour qu'elles deviennent automatiquement disponibles pour tous les fichiers de test situés dans le même répertoire et dans tous les sous-dossiers. Aucun import n'est requis dans vos fichiers de test !
Attention, nous allons prendre un exemple assez complet avec deux fonctions pour lire et écrire dans un fichier.
# main.py def lire_fichier(path): with open(path) as f: return f.read() def ecrire_fichier(path, contenu): with open(path, "w") as f: f.write(contenu)
Un fichier conftest.py avec trois fixtures :
-
contenu_initial: retourne simplement une chaîne de caractères -
fichier_temp: utilise la fixture intégréetmp_pathpour construire un chemin vers un fichiertest.txtdans un dossier temporaire géré parpytest -
avec_yield: illustre l'utilisation deyieldà la place dereturn— le code avant leyields'exécute en setup, le code après en teardown, une fois le test terminé. On ne fait rien de particulier ici, mais en pratique, c'est ce pattern que l'on utilise pour des ressources à cycle de vie : connexion à une base de données, client HTTP, fichier ouvert, etc.
Concernant le yield, je vous invite à regarder les exemples de la documentation officielle.
import pytest @pytest.fixture def contenu_initial(): return "bonjour" @pytest.fixture def fichier_temp(tmp_path): path = tmp_path / "test.txt" return path @pytest.fixture def avec_yield(): print("\n[SETUP] avant le test") yield print("\n[TEARDOWN] après le test")
Le fichier de tests utilise les deux fixtures fichier_temp et contenu_initial définies dans conftest.py. La fixture avec_yield n'est pas utilisée ici — elle servait uniquement d'illustration du pattern yield.
La puissance des fixtures pytest réside ici : il suffit de déclarer le nom de la fixture en paramètre de la fonction de test — pytest s'occupe de l'injecter automatiquement. Pas besoin d'import, pas d'instanciation manuelle. pytest détecte les fixtures disponibles dans conftest.py et les résout à l'exécution.
-
test_lire: reçoitfichier_temp, écrit dedans puis vérifie la lecture -
test_ecrire_puis_lire: combine deux fixtures —fichier_temppour le chemin etcontenu_initialpour la valeur de base, ce qui montre qu'un test peut dépendre de plusieurs fixtures simultanément
from main import lire_fichier, ecrire_fichier def test_lire(fichier_temp): ecrire_fichier(fichier_temp, "bonjour") assert lire_fichier(fichier_temp) == "bonjour" def test_ecrire_puis_lire(fichier_temp, contenu_initial): ecrire_fichier(fichier_temp, contenu_initial + " monde") assert lire_fichier(fichier_temp) == "bonjour monde"
La durée de vie des fixtures : les scopes
Par défaut, une fixture est exécutée dès qu'un test l'utilise. Les fixtures sont réinitialisées à leur état initial pour chaque test. Mais imaginez une fixture qui se connecte à une base de données : on voudrait éviter de l'exécuter 50 fois si l'on a 50 tests qui utilisent cette fixture.
On utilise alors le paramètre scope :
-
scope="function": le comportement par défaut, exécuté à chaque test -
scope="module": exécuté une seule fois par fichier de test -
scope="session": exécuté une seule fois pour toute la session de tests
Prenons un exemple de code classique pour illustrer le scope session :
# Cette connexion ne se fera qu'une seule fois pour toute la session @pytest.fixture(scope="session") def base_de_donnees(): db = connect_to_database() yield db db.close()
Fixture automatique
Parfois, vous voulez qu'une fixture s'applique automatiquement avant chaque test sans avoir à la passer en paramètre. C'est très pratique pour des actions globales, comme forcer des variables d'environnement, forcer le mode synchrone de Celery pendant les tests, etc.
import pytest @pytest.fixture(autouse=True) def ma_fixture_globale(): # Code qui va s'appliquer partout (avant chaque test) yield # Code de nettoyage éventuel (après chaque test)
Le paramétrage des tests pour éviter la répétition
Si l'on veut tester une fonctionnalité avec des données différentes, au lieu d'écrire plusieurs fois le même test, pytest propose une fonctionnalité : parametrize.
Reprenons notre exemple simple avec la fonction saluer :
# foo.py def saluer(nom): return f"Bonjour {nom}"
# test_foo.py import pytest from foo import saluer @pytest.mark.parametrize("nom, resultat_attendu", [ ("Patrick", "Bonjour Patrick"), ("Sebastien", "Bonjour Sebastien"), ("Ely", "Bonjour Ely"), ]) def test_saluer_plusieurs_personnes(nom, resultat_attendu): assert saluer(nom) == resultat_attendu
pytest va générer trois tests distincts. Les noms des variables passés sous forme de chaîne de caractères dans le décorateur ("nom, resultat_attendu") doivent correspondre exactement aux arguments de notre fonction de test. Cela permet à pytest d'injecter les bonnes valeurs à chaque itération.
Mocker avec pytest
Par exemple, si votre code communique avec une API externe, vous ne voulez pas que vos tests dépendent de la connexion Internet. C'est ici que les mocks interviennent, car ils permettent de simuler une réponse. Et vous savez quoi ? pytest est compatible avec la bibliothèque standard de Python, ce qui permet d'utiliser unittest.mock sans rien installer de plus.
Imaginons une fonction qui interroge l'API météo wttr :
# main.py import requests def obtenir_meteo(ville): reponse = requests.get(f"https://wttr.in/{ville}?format=j1") if reponse.status_code == 200: data = reponse.json() print(data) # Affiche les données pour le débogage temp = data['current_condition'][0]['temp_C'] return f"Il fait {temp}°C à {ville}" return "Météo indisponible" if __name__ == "__main__": ville = "Paris" print(obtenir_meteo(ville))
Créons un test en utilisant unittest.mock pour le mock :
# test_main.py from unittest.mock import patch, Mock from main import obtenir_meteo # On "patch" (intercepte) la fonction requests.get là où elle est utilisée @patch("main.requests.get") def test_obtenir_meteo_succes(mock_get): # 1. On configure la fausse réponse (status 200 et un faux JSON) fausse_reponse = Mock() fausse_reponse.status_code = 200 fausse_reponse.json.return_value = { "current_condition": [ { "temp_C": "25", "temp_F": "77", "humidity": "55", "weatherDesc": [{"value": "Ensoleillé"}], } ] } # 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 le résultat et l'appel assert resultat == "Il fait 25°C à Paris" mock_get.assert_called_once_with("https://wttr.in/Paris?format=j1")
Expliquons le mock étape par étape :
-
@patch('main.requests.get')indique à Python le chemin de la fonction à patcher.requests.getest donc mise de côté et remplacée par un mock -
On définit un paramètre
mock_getqui représente notre mock, passé automatiquement par le décorateur@patch. À noter que le nom du paramètre est libre, maismock_*est une bonne pratique -
mock_get.return_value = fausse_reponsepermet de configurer notre mock pour qu'il renvoie l'objet que l'on vient de créer -
mock_get.assert_called_once_with(...)permet de s'assurer que le code a appelé la fonction avec la bonne URL
À noter
Si le vrai code appelle une méthode ou une fonction, on utilise return_value.
Si le vrai code accède à un attribut, on assigne directement une valeur.
fausse_reponse = Mock() fausse_reponse.status_code = 200 fausse_reponse.json.return_value = {...}
À noter
Si vous avez déjà lu notre article sur unittest, ce qui suit n'a aucun secret pour vous 😛 ; vous pouvez passer à la suite.
Pourquoi patcher main.requests.get plutôt que requests.get ?
Dans notre cas, les deux fonctionneraient. Quand main.py importe requests puis appelle requests.get(), get est résolu au moment de l'appel. Donc on pourrait patcher requests.get directement sans problème.
Par contre, si le code source utilisait un import direct from requests import get, main.py posséderait sa propre référence vers get. Donc patcher requests.get ne fonctionnerait plus, et il faudrait cibler main.get.
La bonne pratique est donc de cibler la référence dans le module testé : main.requests.get.
La règle d'or à retenir : patcher là où la fonction est utilisée, pas là où elle est définie.
Simuler une erreur : side_effect
Il est possible que l'API tombe en panne et il faut aussi tester ce cas. Au lieu d'utiliser return_value pour simuler une réponse, on utilise side_effect pour lever une exception :
import pytest import requests from unittest.mock import patch from main import obtenir_meteo @patch("main.requests.get") def test_obtenir_meteo_erreur(mock_get): # On simule une coupure internet mock_get.side_effect = requests.exceptions.ConnectionError() # On s'assure que notre code propage bien l'erreur attendue with pytest.raises(requests.exceptions.ConnectionError): obtenir_meteo("Londres")
Le plugin pytest-mock
À savoir qu'il est possible d'utiliser pytest-mock, qui fournit la fixture mocker. Elle permet de configurer les mocks directement dans la fonction :
pip install pytest-mock
def test_obtenir_meteo_erreur(mocker): mock_get = mocker.patch("main.requests.get") mock_get.side_effect = requests.exceptions.ConnectionError() with pytest.raises(requests.exceptions.ConnectionError): obtenir_meteo("Londres")
Cela permet de se passer du décorateur, dont le comportement peut parfois surprendre lorsque l'on empile plusieurs mocks. En effet, si l'on empile plusieurs @patch, les arguments doivent être déclarés dans l'ordre inverse :
# unittest.mock — les arguments s'accumulent à l'envers @patch("main.requests.get") @patch("main.cache.get") @patch("main.logger.error") def test_foo(mock_logger, mock_cache, mock_get): # ordre inversé ! ... # pytest-mock — linéaire et dans l'ordre def test_foo(mocker): mock_get = mocker.patch("main.requests.get") mock_cache = mocker.patch("main.cache.get") mock_logger = mocker.patch("main.logger.error") ...
Pour aller plus loin
Les marqueurs personnalisés
Imaginez avoir une multitude de tests dans votre projet ; certains peuvent être plus longs à s'exécuter. Il est possible de créer des marqueurs pour vos tests :
# main.py import random def lancer_de(): return random.randint(1, 6) if __name__ == "__main__": print("Vous avez lancé un dé et obtenu :", lancer_de())
import pytest from main import lancer_de def test_lancer_de_dans_intervalle(): resultat = lancer_de() assert 1 <= resultat <= 6 @pytest.mark.lent def test_lancer_de_plusieurs_fois(): for _ in range(1000): resultat = lancer_de() assert 1 <= resultat <= 6
Je peux maintenant lancer les tests marqués comme lent ou les ignorer :
pytest -m "lent" pytest -m "not lent"
Marqueurs pytest
J'ai bien un test qui passe sur les deux, mais j'ai un avertissement. Il faut déclarer vos marqueurs personnalisés dans un fichier de configuration pytest.ini :
# pytest.ini [pytest] markers = lent: Marque un test comme étant particulièrement lent à exécuter.
Ce fichier pytest.ini sert à configurer de nombreux autres comportements globaux de pytest.
La couverture de code
En installant pytest-cov, il est possible de savoir quel pourcentage de votre code a été parcouru par les tests.
pip install pytest-cov
Vous pouvez ensuite lancer cette commande :
pytest --cov
Voici un exemple avec l'une de mes applications ; je ne présente que le haut et le bas du rapport :
Haut du rapport
Bas du rapport
Vous avez ici un tableau avec plusieurs colonnes :
-
le nombre de lignes exécutables (Stmts)
-
le nombre de lignes qui n'ont pas été exécutées pendant vos tests (Miss)
-
le pourcentage de couverture (Cover)
Prochaine étape : intégrer pytest avec GitHub Actions pour déclencher les tests dès que vous pousserez votre code. Nous avons d'ailleurs un article sur le sujet 😁.