Patrons de Test Python
Stratégies complètes de test pour applications Python utilisant pytest, la méthodologie TDD et les meilleures pratiques.
Quand Activer
- Écrire du nouveau code Python (suivre TDD : rouge, vert, refactor)
- Concevoir des suites de tests pour projets Python
- Examiner la couverture de test Python
- Mettre en place l'infrastructure de test
Philosophie Centrale du Test
Développement Piloté par les Tests (TDD)
Suivez toujours le cycle TDD :
- ROUGE : Écrire un test échouant pour le comportement souhaité
- VERT : Écrire un code minimal pour faire passer le test
- REFACTOR : Améliorer le code tout en gardant les tests verts
# Étape 1 : Écrire un test échouant (ROUGE)
def test_add_numbers():
resultat = addition(2, 3)
assert resultat == 5
# Étape 2 : Écrire une implémentation minimale (VERT)
def addition(a, b):
return a + b
# Étape 3 : Refactoriser si nécessaire (REFACTOR)
Exigences de Couverture
- Cible : 80%+ de couverture de code
- Chemins critiques : 100% de couverture requis
- Utilisez
pytest --covpour mesurer la couverture
pytest --cov=mypackage --cov-report=term-missing --cov-report=html
Fondamentaux de pytest
Structure de Base du Test
import pytest
def test_addition():
"""Test de l'addition de base."""
assert 2 + 2 == 4
def test_majuscule_chaine():
"""Test de la mise en majuscule d'une chaîne."""
texte = "bonjour"
assert texte.upper() == "BONJOUR"
def test_ajout_liste():
"""Test d'ajout à une liste."""
elements = [1, 2, 3]
elements.append(4)
assert 4 in elements
assert len(elements) == 4
Assertions
# Égalité
assert resultat == attendu
# Inégalité
assert resultat != inattendu
# Véracité
assert resultat # Vrai
assert not resultat # Faux
assert resultat is True # Exactement True
assert resultat is False # Exactement False
assert resultat is None # Exactement None
# Appartenance
assert element in collection
assert element not in collection
# Comparaisons
assert resultat > 0
assert 0 <= resultat <= 100
# Vérification de type
assert isinstance(resultat, str)
# Test d'exception (approche préférée)
with pytest.raises(ValueError):
raise ValueError("message d'erreur")
# Vérifier le message d'exception
with pytest.raises(ValueError, match="entrée invalide"):
raise ValueError("entrée invalide fournie")
# Vérifier les attributs de l'exception
with pytest.raises(ValueError) as exc_info:
raise ValueError("message d'erreur")
assert str(exc_info.value) == "message d'erreur"
Fixtures
Utilisation de Base des Fixtures
import pytest
@pytest.fixture
def donnees_exemple():
"""Fixture fournissant des données d'exemple."""
return {"nom": "Alice", "age": 30}
def test_donnees_exemple(donnees_exemple):
"""Test utilisant la fixture."""
assert donnees_exemple["nom"] == "Alice"
assert donnees_exemple["age"] == 30
Fixture avec Configuration/Nettoyage
@pytest.fixture
def base_donnees():
"""Fixture avec configuration et nettoyage."""
# Configuration
db = BaseDonnees(":memory:")
db.creer_tables()
db.inserer_donnees_test()
yield db # Fournir au test
# Nettoyage
db.fermer()
def test_requete_base_donnees(base_donnees):
"""Test des opérations sur la base de données."""
resultat = base_donnees.requete("SELECT * FROM utilisateurs")
assert len(resultat) > 0
Portées des Fixtures
# Portée fonction (défaut) - s'exécute pour chaque test
@pytest.fixture
def fichier_temporaire():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# Portée module - s'exécute une fois par module
@pytest.fixture(scope="module")
def db_module():
db = BaseDonnees(":memory:")
db.creer_tables()
yield db
db.fermer()
# Portée session - s'exécute une fois par session de test
@pytest.fixture(scope="session")
def ressource_partagee():
ressource = RessourceCouteuse()
yield ressource
ressource.nettoyer()
Fixture avec Paramètres
@pytest.fixture(params=[1, 2, 3])
def nombre(request):
"""Fixture paramétrée."""
return request.param
def test_nombres(nombre):
"""Le test s'exécute 3 fois, une fois pour chaque paramètre."""
assert nombre > 0
Utilisation de Plusieurs Fixtures
@pytest.fixture
def utilisateur():
return Utilisateur(id=1, nom="Alice")
@pytest.fixture
def administrateur():
return Utilisateur(id=2, nom="Admin", role="admin")
def test_interaction_utilisateur_admin(utilisateur, administrateur):
"""Test utilisant plusieurs fixtures."""
assert administrateur.peut_gerer(utilisateur)
Fixtures Autouse
@pytest.fixture(autouse=True)
def reinitialiser_config():
"""S'exécute automatiquement avant chaque test."""
Config.reinitialiser()
yield
Config.nettoyer()
def test_sans_appel_fixture():
# reinitialiser_config s'exécute automatiquement
assert Config.obtenir_parametre("debug") is False
Conftest.py pour les Fixtures Partagées
# tests/conftest.py
import pytest
@pytest.fixture
def client():
"""Fixture partagée pour tous les tests."""
app = creer_app(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def entetes_auth(client):
"""Générer les en-têtes d'authentification pour les tests API."""
reponse = client.post("/api/connexion", json={
"nom_utilisateur": "test",
"mot_de_passe": "test"
})
jeton = reponse.json["jeton"]
return {"Authorization": f"Bearer {jeton}"}
Paramétrage
Paramétrage de Base
@pytest.mark.parametrize("entree,attendu", [
("bonjour", "BONJOUR"),
("monde", "MONDE"),
("PyThOn", "PYTHON"),
])
def test_majuscule(entree, attendu):
"""Le test s'exécute 3 fois avec différentes entrées."""
assert entree.upper() == attendu
Paramètres Multiples
@pytest.mark.parametrize("a,b,attendu", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_addition(a, b, attendu):
"""Test de l'addition avec plusieurs entrées."""
assert addition(a, b) == attendu
Paramétrage avec Identifiants
@pytest.mark.parametrize("entree,attendu", [
("[email protected]", True),
("invalide", False),
("@sans-domaine.com", False),
], ids=["email-valide", "manque-arobase", "manque-domaine"])
def test_validation_email(entree, attendu):
"""Test de validation d'email avec des identifiants lisibles."""
assert est_email_valide(entree) is attendu
Fixtures Paramétrées
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""Test avec plusieurs moteurs de base de données."""
if request.param == "sqlite":
return BaseDonnees(":memory:")
elif request.param == "postgresql":
return BaseDonnees("postgresql://localhost/test")
elif request.param == "mysql":
return BaseDonnees("mysql://localhost/test")
def test_operations_base_donnees(db):
"""Le test s'exécute 3 fois, une fois pour chaque base."""
resultat = db.requete("SELECT 1")
assert resultat is not None
Marqueurs et Sélection de Tests
Marqueurs Personnalisés
# Marquer les tests lents
@pytest.mark.slow
def test_operation_lente():
time.sleep(5)
# Marquer les tests d'intégration
@pytest.mark.integration
def test_integration_api():
reponse = requests.get("https://api.exemple.com")
assert reponse.status_code == 200
# Marquer les tests unitaires
@pytest.mark.unit
def test_logique_unitaire():
assert calculer(2, 3) == 5
Exécuter des Tests Spécifiques
# Exécuter uniquement les tests rapides
pytest -m "not slow"
# Exécuter uniquement les tests d'intégration
pytest -m integration
# Exécuter les tests d'intégration ou lents
pytest -m "integration or slow"
# Exécuter les tests marqués comme unitaires mais pas lents
pytest -m "unit and not slow"
Configurer les Marqueurs dans pytest.ini
[pytest]
markers =
slow: marque les tests comme lents
integration: marque les tests comme tests d'intégration
unit: marque les tests comme tests unitaires
django: marque les tests comme nécessitant Django
Mocking et Patching
Mocking de Fonctions
from unittest.mock import patch, Mock
@patch("mypackage.appel_api_externe")
def test_avec_mock(appel_api_mock):
"""Test avec une API externe mockée."""
appel_api_mock.return_value = {"statut": "succès"}
resultat = ma_fonction()
appel_api_mock.assert_called_once()
assert resultat["statut"] == "succès"
Mocking de Valeurs de Retour
@patch("mypackage.BaseDonnees.connecter")
def test_connexion_base_donnees(connecter_mock):
"""Test avec une connexion base de données mockée."""
connecter_mock.return_value = MockConnexion()
db = BaseDonnees()
db.connecter()
connecter_mock.assert_called_once_with("localhost")
Mocking d'Exceptions
@patch("mypackage.appel_api")
def test_gestion_erreur_api(appel_api_mock):
"""Test de la gestion d'erreur avec exception mockée."""
appel_api_mock.side_effect = ErreurConnexion("Erreur réseau")
with pytest.raises(ErreurConnexion):
appel_api()
appel_api_mock.assert_called_once()
Mocking de Gestionnaires de Contexte
@patch("builtins.open", new_callable=mock_open)
def test_lecture_fichier(fichier_mock):
"""Test de lecture de fichier avec open mocké."""
fichier_mock.return_value.read.return_value = "contenu du fichier"
resultat = lire_fichier("test.txt")
fichier_mock.assert_called_once_with("test.txt", "r")
assert resultat == "contenu du fichier"
Utilisation d'Autospec
@patch("mypackage.ConnexionBD", autospec=True)
def test_autospec(db_mock):
"""Test avec autospec pour détecter une mauvaise utilisation de l'API."""
db = db_mock.return_value
db.requete("SELECT * FROM utilisateurs")
# Ceci échouerait si ConnexionBD n'a pas de méthode requete
db_mock.assert_called_once()
Mocking d'Instances de Classe
class TestServiceUtilisateur:
@patch("mypackage.DepotUtilisateurs")
def test_creer_utilisateur(self, depot_mock):
"""Test de création d'utilisateur avec dépôt mocké."""
depot_mock.return_value.sauvegarder.return_value = Utilisateur(id=1, nom="Alice")
service = ServiceUtilisateur(depot_mock.return_value)
utilisateur = service.creer_utilisateur(nom="Alice")
assert utilisateur.nom == "Alice"
depot_mock.return_value.sauvegarder.assert_called_once()
Mocking de Propriété
@pytest.fixture
def mock_config():
"""Créer un mock avec une propriété."""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).cle_api = PropertyMock(return_value="cle-test")
return config
def test_avec_mock_config(mock_config):
"""Test avec des propriétés de configuration mockées."""
assert mock_config.debug is True
assert mock_config.cle_api == "cle-test"
Test de Code Asynchrone
Tests Async avec pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_fonction_async():
"""Test d'une fonction asynchrone."""
resultat = await addition_async(2, 3)
assert resultat == 5
@pytest.mark.asyncio
async def test_async_avec_fixture(client_async):
"""Test asynchrone avec fixture asynchrone."""
reponse = await client_async.get("/api/utilisateurs")
assert reponse.status_code == 200
Fixture Asynchrone
@pytest.fixture
async def client_async():
"""Fixture asynchrone fournissant un client de test asynchrone."""
app = creer_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_point_acces_api(client_async):
"""Test utilisant une fixture asynchrone."""
reponse = await client_async.get("/api/donnees")
assert reponse.status_code == 200
Mocking de Fonctions Async
@pytest.mark.asyncio
@patch("mypackage.appel_api_async")
async def test_mock_async(appel_api_mock):
"""Test d'une fonction asynchrone avec mock."""
appel_api_mock.return_value = {"statut": "ok"}
resultat = await ma_fonction_async()
appel_api_mock.assert_awaited_once()
assert resultat["statut"] == "ok"
Test des Exceptions
Test des Exceptions Attendues
def test_division_par_zero():
"""Test que la division par zéro lève ZeroDivisionError."""
with pytest.raises(ZeroDivisionError):
diviser(10, 0)
def test_exception_personnalisee():
"""Test d'une exception personnalisée avec message."""
with pytest.raises(ValueError, match="entrée invalide"):
valider_entree("invalide")
Test des Attributs d'Exception
def test_exception_avec_details():
"""Test d'une exception avec attributs personnalisés."""
with pytest.raises(ErreurPersonnalisee) as exc_info:
raise ErreurPersonnalisee("erreur", code=400)
assert exc_info.value.code == 400
assert "erreur" in str(exc_info.value)
Test des Effets de Bord
Test des Opérations sur Fichiers
import tempfile
import os
def test_traitement_fichier():
"""Test du traitement de fichier avec fichier temporaire."""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("contenu de test")
chemin_temp = f.name
try:
resultat = traiter_fichier(chemin_temp)
assert resultat == "traité: contenu de test"
finally:
os.unlink(chemin_temp)
Test avec la Fixture tmp_path de pytest
def test_avec_chemin_temp(tmp_path):
"""Test utilisant la fixture de chemin temporaire intégrée de pytest."""
fichier_test = tmp_path / "test.txt"
fichier_test.write_text("bonjour le monde")
resultat = traiter_fichier(str(fichier_test))
assert resultat == "bonjour le monde"
# tmp_path est automatiquement nettoyé
Test avec la Fixture tmpdir
def test_avec_repertoire_temp(tmpdir):
"""Test utilisant la fixture tmpdir de pytest."""
fichier_test = tmpdir.join("test.txt")
fichier_test.write("données")
resultat = traiter_fichier(str(fichier_test))
assert resultat == "données"
Organisation des Tests
Structure des Répertoires
tests/
├── conftest.py # Fixtures partagées
├── __init__.py
├── unit/ # Tests unitaires
│ ├── __init__.py
│ ├── test_modeles.py
│ ├── test_utilitaires.py
│ └── test_services.py
├── integration/ # Tests d'intégration
│ ├── __init__.py
│ ├── test_api.py
│ └── test_base_donnees.py
└── e2e/ # Tests de bout en bout
├── __init__.py
└── test_parcours_utilisateur.py
Classes de Test
class TestServiceUtilisateur:
"""Grouper les tests liés dans une classe."""
@pytest.fixture(autouse=True)
def mise_en_place(self):
"""Configuration exécutée avant chaque test de cette classe."""
self.service = ServiceUtilisateur()
def test_creer_utilisateur(self):
"""Test de création d'utilisateur."""
utilisateur = self.service.creer_utilisateur("Alice")
assert utilisateur.nom == "Alice"
def test_supprimer_utilisateur(self):
"""Test de suppression d'utilisateur."""
utilisateur = self.service.creer_utilisateur("Bob")
self.service.supprimer_utilisateur(utilisateur.id)
assert self.service.obtenir_utilisateur(utilisateur.id) is None
def test_mettre_a_jour_utilisateur(self):
"""Test de mise à jour d'utilisateur."""
utilisateur = self.service.creer_utilisateur("Charlie")
utilisateur_mis_a_jour = self.service.mettre_a_jour_utilisateur(
utilisateur.id, nom="Charles"
)
assert utilisateur_mis_a_jour.nom == "Charles"
Bonnes Pratiques et Conseils
Organisation des Tests
- Un test = un comportement : Chaque test doit vérifier une seule chose
- Nommer clairement :
test_fonctionnalite_testee_condition_attendue - Utiliser l'arrangement, l'action, l'assertion (AAA) : Configurer, exécuter, vérifier
- Éviter les tests fragiles : Tester le comportement, pas l'implémentation
- Garder les tests indépendants : Un test ne doit pas dépendre d'un autre
Exemple de Structure AAA
def test_calculer_reduction_membre_fidele():
# Arrangement
membre = Membre(niveau="fidèle", total_achats=1000)
montant_achat = 200
# Action
reduction = calculer_reduction(membre, montant_achat)
# Assertion
assert reduction == 40 # 20% de réduction pour les membres fidèles
assert reduction <= montant_achat * 0.2
Configuration pytest
[pytest]
# Fichier pytest.ini recommandé
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
afd_opts = -ra -q --tb=short
markers =
lent: tests lents
integration: tests d'intégration
unite: tests unitaires
smoke: tests de fumée
# Couverture
addopts =
--cov=mypackage
--cov-report=term-missing
--cov-report=html
Exemple complet de test de service
import pytest
from unittest.mock import patch, Mock
@pytest.mark.unite
class TestServiceCommandes:
"""Tests unitaires pour ServiceCommandes."""
@pytest.fixture(autouse=True)
def mise_en_place(self):
"""Configurer les mocks communs."""
self.depot_commandes = Mock()
self.service = ServiceCommandes(depot_commandes=self.depot_commandes)
@pytest.mark.parametrize("statut_commande,prix_attendu", [
("en_attente", 100.0),
("expédiée", 100.0),
("livrée", 100.0),
("annulée", 0.0),
])
def test_calculer_prix_selon_statut(self, statut_commande, prix_attendu):
"""Doit retourner le prix correct selon le statut de la commande."""
commande = Commande(statut=statut_commande, montant=100.0)
self.depot_commandes.obtenir_par_id.return_value = commande
prix = self.service.calculer_prix(commande_id=1)
assert prix == prix_attendu
@patch("mypackage.service.envoi_email")
def test_envoyer_confirmation_commande(self, envoi_email_mock):
"""Doit envoyer un email de confirmation après création de commande."""
commande = self.service.creer_commande(
utilisateur_id=1,
articles=[Article(id=1, prix=50.0)]
)
envoi_email_mock.assert_called_once_with(
destinataire="[email protected]",
sujet="Confirmation de commande",
corps=f"Votre commande {commande.id} a été créée."
)
def test_creer_commande_invalide_articles_vides(self):
"""Doit lever une exception pour une commande sans articles."""
with pytest.raises(ValueError, match="Au moins un article requis"):
self.service.creer_commande(
utilisateur_id=1,
articles=[]
)
Ressources Supplémentaires
- Documentation officielle de pytest
- Guide des bonnes pratiques de test
- Bibliothèque unittest.mock
- pytest-cov pour la couverture
- pytest-asyncio pour le code asynchrone
Pièges Courants à Éviter
- Tests dépendants de l'ordre : Ne pas partager l'état entre les tests
- Tests trop larges : Tester une seule préoccupation par test
- Négliger les cas limites : Tester les cas vides, nuls et limites
- Sous-estimer les fixtures : Utiliser conftest.py pour les fixtures partagées
- Ignorer les tests async : Toujours utiliser
pytest-asynciopour les fonctions async - Sauts de couverture : Viser 80% et 100% sur les chemins critiques