backend / python
Tests avec pytest : fixtures et mocks
Explication
Ce que vous allez apprendre
- Écrire un test pytest lisible avec une simple fonction
test_...et unassert - Tester plusieurs cas avec
@pytest.mark.parametrizesans dupliquer le code de test - Utiliser une fixture pour préparer et nettoyer un contexte de test automatiquement
- Isoler le code testé de ses dépendances externes avec
unittest.mock.Mocketpatch - Vérifier qu'une dépendance mockée a bien été appelée avec
assert_called_once_with
Dans quel contexte ?
Une pull request échoue en intégration continue avec le message AssertionError: assert 21 == 20 sur un test test_calcul_remise. Sans ce test automatisé, ce bug de calcul de remise serait passé inaperçu jusqu'à ce qu'un client se plaigne d'une facture incorrecte. Un test qui échoue en CI avant la mise en production, plutôt qu'un bug découvert par un utilisateur, est exactement la raison d'être des tests automatisés.
Pourquoi on ne vérifie plus son code à l'œil
Relancer manuellement son programme après chaque modification pour vérifier que rien n'est cassé devient vite intenable dès que le projet grossit. Un test automatisé encode cette vérification une fois pour toutes : on l'écrit, et on peut le relancer en une seconde autant de fois que nécessaire, à chaque modification future, sans jamais oublier un cas qu'on aurait vérifié manuellement autrefois.
pytest, ou tester sans cérémonie
Contrairement à d'autres frameworks de test plus verbeux, pytest permet d'écrire un test comme une simple fonction commençant par test_, avec un assert classique. Pas besoin d'hériter d'une classe spéciale ni d'apprendre une API de comparaison dédiée : c'est l'une des raisons de son adoption massive dans l'écosystème Python.
| Besoin | Outil pytest |
|---|---|
| Vérifier un résultat | assert resultat == attendu |
| Vérifier qu'une exception est levée | with pytest.raises(ValueError): |
| Tester plusieurs jeux de données | @pytest.mark.parametrize(...) |
| Préparer/nettoyer un contexte | @pytest.fixture avec yield |
| Isoler une dépendance externe | unittest.mock.patch |
Fixtures : préparer un contexte sans le répéter
Beaucoup de tests ont besoin du même objet préparé en amont (une instance, une connexion simulée). Plutôt que de dupliquer ce code de préparation dans chaque test, une fixture le centralise : pytest l'exécute automatiquement et l'injecte dans chaque test qui la déclare en paramètre. Le yield à l'intérieur d'une fixture sépare la préparation (avant) du nettoyage (après), un écho direct des générateurs et context managers vus précédemment.
Isoler ce qu'on teste vraiment
Un test unitaire ne devrait pas dépendre d'une vraie base de données ou d'un vrai appel réseau, sous peine d'être lent et fragile. Les mocks (unittest.mock) remplacent temporairement une dépendance externe par un objet factice dont on contrôle entièrement le comportement, permettant de tester la logique propre du code sans dépendre de son environnement.
Bonne pratique
Un test unitaire qui appelle une vraie API météo pour tester ServiceMeteo.temperature_actuelle() est lent, fragile (que se passe-t-il si l'API est down ?) et coûteux à grande échelle. Remplacez le client HTTP par un Mock dont vous contrôlez la réponse : le test devient instantané et fiable, peu importe l'état du réseau.
Commandes & code
Tests avec pytest : fixtures et mocks
Écrire des tests fiables, lisibles et rapides.
# fichier : calculatrice.py
class Calculatrice:
def additionner(self, a, b):
return a + b
def diviser(self, a, b):
if b == 0:
raise ZeroDivisionError("Division par zero")
return a / b
class ServiceMeteo:
def __init__(self, client_api):
self.client_api = client_api
def temperature_actuelle(self, ville):
reponse = self.client_api.get(f"/meteo/{ville}")
return reponse["temperature"]# fichier : test_calculatrice.py
import pytest
from calculatrice import Calculatrice, ServiceMeteo
# Test simple : fonction qui commence par test_
def test_addition():
calc = Calculatrice()
assert calc.additionner(2, 3) == 5
def test_division_par_zero_leve_exception():
calc = Calculatrice()
with pytest.raises(ZeroDivisionError):
calc.diviser(10, 0)
# Parametrize : executer le meme test avec plusieurs jeux de donnees
@pytest.mark.parametrize("a, b, attendu", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, -50, 50),
])
def test_addition_parametree(a, b, attendu):
calc = Calculatrice()
assert calc.additionner(a, b) == attendu
# Fixture : prepare des donnees/objets reutilisables entre tests
@pytest.fixture
def calculatrice():
print("Setup de la fixture")
calc = Calculatrice()
yield calc # tout ce qui suit yield = teardown
print("Teardown de la fixture")
def test_avec_fixture(calculatrice):
assert calculatrice.additionner(1, 1) == 2
# Fixtures avec portee (scope) : "function" (defaut), "module", "session"
@pytest.fixture(scope="module")
def connexion_bdd_partagee():
print("Connexion BDD ouverte une seule fois pour tout le module")
connexion = {"active": True}
yield connexion
print("Connexion BDD fermee")
# Fixtures parametrees
@pytest.fixture(params=[1, 2, 3])
def nombre(request):
return request.param
def test_carre_positif(nombre):
assert nombre ** 2 >= 0
# --- Mocks : isoler le code teste de ses dependances externes ---
from unittest.mock import Mock, MagicMock, patch
def test_service_meteo_avec_mock():
client_mock = Mock()
client_mock.get.return_value = {"temperature": 22}
service = ServiceMeteo(client_mock)
resultat = service.temperature_actuelle("Paris")
assert resultat == 22
client_mock.get.assert_called_once_with("/meteo/Paris") # verifie l'appel
# patch : remplacer temporairement un objet/fonction pendant le test
def appeler_api_externe():
import requests
return requests.get("https://api.example.com/data").json()
@patch("requests.get")
def test_appel_api_mocke(mock_get):
mock_get.return_value.json.return_value = {"cle": "valeur"}
resultat = appeler_api_externe()
assert resultat == {"cle": "valeur"}
# patch en tant que context manager
def test_avec_patch_context():
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
import requests
reponse = requests.get("https://example.com")
assert reponse.status_code == 200
# side_effect : simuler des comportements dynamiques (exceptions, sequences de valeurs)
def test_side_effect():
mock = Mock(side_effect=[1, 2, ValueError("erreur simulee")])
assert mock() == 1
assert mock() == 2
with pytest.raises(ValueError):
mock()
# Marqueurs : ignorer, isoler ou categoriser des tests
@pytest.mark.skip(reason="Fonctionnalite pas encore implementee")
def test_fonctionnalite_future():
...
@pytest.mark.slow
def test_traitement_lourd():
...pytest # lance tous les tests du dossier courant
pytest test_calculatrice.py # un fichier precis
pytest -k "addition" # filtre par nom de test
pytest -v # mode verbeux
pytest --cov=mon_package # couverture de code (necessite pytest-cov)
pytest -m "not slow" # exclut les tests marques "slow"Résumé
- Les fixtures pytest gèrent le setup/teardown ;
yieldsépare préparation et nettoyage. @pytest.mark.parametrizeévite la duplication pour tester plusieurs cas.unittest.mock.Mock/patchisolent le code testé de ses dépendances externes (API, BDD...).assert_called_once_withvérifie qu'une dépendance mockée a été appelée comme attendu.
Exercices pratiques
Mission : fiabiliser des tests météo qui dépendent d'internet
Objectif : Isoler un service de test d'une dépendance réseau réelle avec des mocks, et supprimer la duplication de tests avec parametrize.
Contexte
Les tests de ServiceMeteo.temperature_actuelle() appellent réellement une API météo publique à chaque exécution de la CI. Certains jours, les tests échouent parce que l'API est temporairement indisponible, ce qui n'a rien à voir avec la qualité du code testé. En parallèle, test_addition_1, test_addition_2 et test_addition_3 dupliquent presque le même code trois fois.
Tu dois remplacer le client réseau par un mock contrôlé, factoriser les trois tests dupliqués, et comprendre exactement ce que vérifie assert_called_once_with.