Chapitre 45 : Les Tests avec Pytest
Objectif
Ce module a pour but de vous initier à pytest, le framework de test le plus populaire et le plus puissant de l'écosystème Python. Vous apprendrez à écrire des tests simples, à utiliser les fixtures pour préparer le contexte de vos tests, et à comprendre la philosophie de pytest qui privilégie la simplicité et la lisibilité.
1. Pourquoi Écrire des Tests ?
Les tests automatisés sont une partie cruciale du développement logiciel moderne. Ils permettent de :
- Vérifier la correction : S'assurer que votre code fait ce qu'il est censé faire.
- Prévenir les régressions : Garantir que les nouvelles modifications n'ont pas cassé des fonctionnalités existantes.
- Faciliter le refactoring : Vous pouvez modifier votre code en toute confiance, sachant que la suite de tests vous alertera si quelque chose ne va plus.
- Servir de documentation : Les tests montrent comment votre code est censé être utilisé.
2. pytest vs unittest
unittest est le framework de test inclus dans la bibliothèque standard de Python. pytest est un package tiers qui s'est imposé comme le standard de facto grâce à ses nombreux avantages :
- Syntaxe plus simple : Pas besoin de créer des classes qui héritent de
unittest.TestCase. De simples fonctions suffisent. - Assertions plus claires : On utilise l'instruction
assertstandard de Python.pytestse charge de fournir des messages d'erreur détaillés en cas d'échec. - Découverte de tests puissante :
pytesttrouve et exécute automatiquement vos tests si vous suivez des conventions de nommage simples. - Fixtures : Un système d'injection de dépendances élégant et puissant pour gérer l'état et le contexte des tests.
- Écosystème de plugins riche : Des centaines de plugins pour étendre les fonctionnalités de
pytest(tests de couverture, tests asynchrones, intégration avec Django/Flask, etc.).
3. Écrire son Premier Test avec pytest
-
Installation :
pip install pytest -
Conventions de nommage :
- Les fichiers de test doivent être nommés
test_*.pyou*_test.py. - Les fonctions de test à l'intérieur de ces fichiers doivent être nommées
test_*(). - Les classes de test (optionnelles) doivent être nommées
Test*.
- Les fichiers de test doivent être nommés
-
Exemple : Créez un fichier
calculs.pyavec une fonction à tester :# calculs.py
def addition(a, b):
return a + bCréez un fichier de test
test_calculs.pydans le même dossier :# test_calculs.py
from calculs import addition
# Une simple fonction de test
def test_addition():
# L'assertion est simple et directe
assert addition(2, 3) == 5
assert addition(-1, 1) == 0
assert addition(-1, -1) == -2 -
Lancer les tests : Ouvrez un terminal dans le dossier et lancez simplement la commande
pytest:pytest -
Résultat :
============================= test session starts ==============================
...
collected 1 item
test_calculs.py . [100%]
============================== 1 passed in ...s ===============================Le
.indique qu'un test a réussi. Si un test échoue, vous verrez unF.
Exemple d'un test qui échoue
Modifions le test pour qu'il échoue :
# test_calculs.py
def test_addition_echec():
assert addition(2, 3) == 6 # Assertion incorrecte
pytest nous donne un rapport d'erreur très détaillé :
...
def test_addition_echec():
> assert addition(2, 3) == 6
E assert 5 == 6
E + where 5 = addition(2, 3)
test_calculs.py:10: AssertionError
=========================== 1 failed, 1 passed in ...s ===========================
pytest a ré-écrit l'assertion pour nous montrer exactement quelles valeurs ont été comparées. C'est beaucoup plus informatif qu'un simple AssertionError.
4. Les Fixtures : Gérer le Contexte des Tests
Les fixtures sont le cœur de la puissance de pytest. Une fixture est une fonction qui prépare des données ou un état nécessaire pour un test. pytest les exécute avant le test qui en a besoin et "injecte" leur résultat dans le test.
On déclare une fixture avec le décorateur @pytest.fixture.
Exemple simple de fixture
Imaginons que plusieurs tests ont besoin d'une liste de nombres de départ.
# test_liste.py
import pytest
@pytest.fixture
def liste_nombres():
"""Une fixture qui fournit une liste de nombres pour les tests."""
print("\n(Création de la liste via la fixture)")
return [1, 2, 3, 4, 5]
def test_longueur_liste(liste_nombres):
"""Teste la longueur de la liste fournie par la fixture."""
# 'liste_nombres' est le résultat de la fixture
assert len(liste_nombres) == 5
def test_somme_liste(liste_nombres):
"""Teste la somme de la liste fournie par la fixture."""
assert sum(liste_nombres) == 15
- Pour utiliser une fixture, on l'ajoute simplement comme argument à la fonction de test.
pytestvoit l'argument, cherche une fixture avec le même nom, l'exécute, et passe sa valeur de retour au test.- Par défaut, la fixture est exécutée une fois pour chaque test qui l'utilise, garantissant l'isolation des tests.
Portée des Fixtures (scope)
On peut contrôler la fréquence d'exécution d'une fixture avec l'argument scope.
scope='function'(défaut) : Exécutée une fois par test.scope='class': Exécutée une fois par classe de test.scope='module': Exécutée une fois par fichier de test.scope='session': Exécutée une fois pour toute la session de test.
C'est utile pour des opérations de configuration coûteuses (ex: connexion à une base de données, création d'un gros objet).
@pytest.fixture(scope='module')
def connexion_db():
"""Fixture coûteuse, exécutée une seule fois pour tout le module."""
print("\n(Connexion à la base de données...)")
db = ... # Code de connexion
yield db # 'yield' est utilisé pour le nettoyage
print("\n(Fermeture de la connexion à la base de données...)")
db.close()
L'utilisation de yield dans une fixture permet de séparer la partie "setup" (avant yield) de la partie "teardown" (après yield), qui sera exécutée à la fin de la portée de la fixture.
5. Paramétrer les Tests
Le décorateur @pytest.mark.parametrize permet d'exécuter le même test avec plusieurs jeux de données différents.
import pytest
@pytest.mark.parametrize("a, b, attendu", [
(2, 3, 5), # Test 1
(-1, 1, 0), # Test 2
(0, 0, 0), # Test 3
(-5, -5, -10), # Test 4
])
def test_addition_parametrise(a, b, attendu):
assert addition(a, b) == attendu
Ce code définit un seul test, mais pytest l'exécutera quatre fois, une pour chaque tuple de données. C'est une manière très propre et lisible de tester de nombreux cas.
6. Quelques Fonctionnalités Utiles
-
Tester les exceptions :
def test_division_par_zero():
with pytest.raises(ZeroDivisionError):
1 / 0Le test réussit si une
ZeroDivisionErrorest levée dans le blocwith. -
Marqueurs (
markers) : Organiser les tests en catégories.@pytest.mark.slow
def test_tres_lent():
...
@pytest.mark.network
def test_appel_api():
...On peut ensuite lancer uniquement une catégorie de tests :
pytest -m slow. -
Fichier
conftest.py: Un fichier spécial où l'on peut placer des fixtures pour qu'elles soient disponibles pour tous les tests d'un dossier et de ses sous-dossiers.
Conclusion
pytest est un outil qui transforme l'écriture de tests en une tâche agréable et productive. En utilisant des fonctions de test simples, des assertions claires, et le puissant système de fixtures, vous pouvez construire des suites de tests robustes et maintenables. L'investissement dans l'apprentissage de pytest est l'un des plus rentables pour un développeur Python souhaitant améliorer la qualité de son code.
Exercice 01 : Tester une Classe avec des Fixtures et parametrize
Objectif
Cet exercice a pour but de vous faire utiliser les fonctionnalités de base et intermédiaires de pytest, notamment les fixtures pour la préparation d'objets et @pytest.mark.parametrize pour tester plusieurs cas de figure.
Contexte
Vous allez développer une classe Portefeuille qui gère un solde. Vous écrirez ensuite une suite de tests avec pytest pour valider son comportement dans différentes situations : dépôt, retrait, et retrait non autorisé.
Énoncé
Étape 1 : Créer la classe à tester
Créez un fichier nommé portefeuille.py et définissez la classe Portefeuille.
# portefeuille.py
class SoldeInsuffisantError(Exception):
"""Exception levée lors d'un retrait supérieur au solde."""
pass
class Portefeuille:
"""Classe pour gérer un solde simple."""
def __init__(self, solde_initial: float = 0):
if solde_initial < 0:
raise ValueError("Le solde initial ne peut pas être négatif.")
self._solde = solde_initial
@property
def solde(self) -> float:
return self._solde
def deposer(self, montant: float):
if montant <= 0:
raise ValueError("Le montant du dépôt doit être positif.")
self._solde += montant
def retirer(self, montant: float):
if montant <= 0:
raise ValueError("Le montant du retrait doit être positif.")
if montant > self._solde:
raise SoldeInsuffisantError("Le retrait ne peut pas excéder le solde.")
self._solde -= montant
Étape 2 : Créer le fichier de test et les fixtures
Créez un fichier test_portefeuille.py.
-
Importez
pytestet la classePortefeuille(ainsi que les exceptions). -
Créez deux fixtures :
- Une fixture
portefeuille_videqui retourne une instance dePortefeuilleavec un solde de 0. - Une fixture
portefeuille_avec_20qui retourne une instance dePortefeuilleavec un solde de 20.
- Une fixture
Étape 3 : Écrire les tests
-
Test du solde initial :
- Écrivez un test
test_solde_initialqui utilise la fixtureportefeuille_videet vérifie que son solde est bien de 0. - Écrivez un test
test_solde_initial_avec_montantqui utilise la fixtureportefeuille_avec_20et vérifie que son solde est bien de 20.
- Écrivez un test
-
Test de la méthode
deposer:- Écrivez un test
test_depotqui utilise la fixtureportefeuille_vide, dépose 10, et vérifie que le nouveau solde est de 10.
- Écrivez un test
-
Test de la méthode
retirer:- Écrivez un test
test_retraitqui utilise la fixtureportefeuille_avec_20, retire 10, et vérifie que le nouveau solde est de 10.
- Écrivez un test
-
Test des exceptions :
- Écrivez un test
test_retrait_solde_insuffisantqui utilise la fixtureportefeuille_avec_20et vérifie qu'unSoldeInsuffisantErrorest bien levé lorsqu'on essaie de retirer 30. Utilisezwith pytest.raises(...). - Écrivez un test
test_creation_solde_initial_negatifqui vérifie qu'uneValueErrorest levée lorsqu'on essaie de créer unPortefeuilleavec un solde initial de -10.
- Écrivez un test
-
Paramétrer les tests :
- Les tests pour les dépôts et retraits avec des montants invalides (négatifs ou nuls) sont de bons candidats pour la paramétrisation.
- Créez un test
test_depot_montant_invalideparamétré pour tester qu'un dépôt de 0 et de -10 lève bien uneValueError. - Créez un test
test_retrait_montant_invalideparamétré de la même manière.
Étape 4 : Lancer les tests
- Assurez-vous d'avoir
pytestinstallé (pip install pytest). - Depuis votre terminal, dans le dossier du projet, lancez la commande
pytest.
Résultat Attendu
Tous vos tests doivent passer. pytest devrait découvrir et exécuter tous les tests que vous avez écrits, y compris les cas paramétrés, et afficher un résumé indiquant que tous ont réussi.
============================= test session starts ==============================
...
collected 8 items
test_portefeuille.py ........ [100%]
============================== 8 passed in ...s ================================
(Le nombre de tests peut varier si vous avez structuré votre test paramétré différemment, mais tous doivent passer).
Cliquez ici pour voir un exemple de code de solution
# test_portefeuille.py
import pytest
from portefeuille import Portefeuille, SoldeInsuffisantError
# --- Fixtures ---
@pytest.fixture
def portefeuille_vide():
"""Retourne un portefeuille avec un solde de 0."""
return Portefeuille()
@pytest.fixture
def portefeuille_avec_20():
"""Retourne un portefeuille avec un solde de 20."""
return Portefeuille(20)
# --- Tests ---
def test_solde_initial(portefeuille_vide):
assert portefeuille_vide.solde == 0
def test_solde_initial_avec_montant(portefeuille_avec_20):
assert portefeuille_avec_20.solde == 20
def test_depot(portefeuille_vide):
portefeuille_vide.deposer(10)
assert portefeuille_vide.solde == 10
def test_retrait(portefeuille_avec_20):
portefeuille_avec_20.retirer(10)
assert portefeuille_avec_20.solde == 10
def test_retrait_solde_insuffisant(portefeuille_avec_20):
with pytest.raises(SoldeInsuffisantError):
portefeuille_avec_20.retirer(30)
def test_creation_solde_initial_negatif():
with pytest.raises(ValueError):
Portefeuille(-10)
@pytest.mark.parametrize("montant_invalide", [0, -10, -0.01])
def test_depot_montant_invalide(portefeuille_vide, montant_invalide):
with pytest.raises(ValueError):
portefeuille_vide.deposer(montant_invalide)
@pytest.mark.parametrize("montant_invalide", [0, -20])
def test_retrait_montant_invalide(portefeuille_avec_20, montant_invalide):
with pytest.raises(ValueError):
portefeuille_avec_20.retirer(montant_invalide)