Écrire des tests fiables avec unittest et pytest. Mock, TDD, fixtures, coverage — les outils du développeur professionnel.
Un guide exhaustif des tests unitaires en Python — de la théorie aux outils, mocks avancés inclus. Conçu pour les bacheliers en informatique qui maîtrisent déjà Python.
Un test unitaire est un morceau de code qui vérifie qu'une petite unité de ton programme — une fonction, une méthode — se comporte exactement comme prévu. C'est le filet de sécurité de tout développeur sérieux.
Chaque modification est validée par les tests. Refactoriser sans peur, c'est possible dès que la couverture est bonne.
Un test est une spécification exécutable — plus fiable qu'un commentaire qui peut devenir obsolète.
Du code difficile à tester est souvent mal conçu. Tester pousse à écrire des fonctions simples et découplées.
Les tests s'exécutent à chaque push. Un échec bloque le déploiement — gage de qualité en continu.
Le principe AAA : Chaque test suit — Arrange (préparer), Act (appeler), Assert (vérifier). C'est la structure universelle d'un test lisible et maintenable.
Inclus dans la bibliothèque standard Python, unittest s'inspire de JUnit (Java). Disponible partout sans installation. Verbeux mais structuré et complet.
import unittest def diviser(a, b): if b == 0: raise ValueError("Division par zéro impossible") return a / b class TestDiviser(unittest.TestCase): def setUp(self): """Appelé avant CHAQUE méthode de test""" self.numerateur = 10 def test_division_normale(self): resultat = diviser(self.numerateur, 2) # Act self.assertEqual(resultat, 5.0) # Assert def test_division_par_zero(self): with self.assertRaises(ValueError): diviser(self.numerateur, 0) def test_division_float(self): self.assertAlmostEqual(diviser(1, 3), 0.333, places=2) if __name__ == "__main__": unittest.main()
assert*subTestclass TestMultiples(unittest.TestCase): def test_est_pair(self): cas = [(2, True), (3, False), (0, True), (-4, True)] for n, attendu in cas: with self.subTest(n=n): # Si ce subTest échoue, les autres continuent quand même self.assertEqual(n % 2 == 0, attendu)
En programmation orientée objet, on teste chaque méthode publique de la classe, ainsi que les interactions entre objets. La règle d'or : un test = un comportement, pas une méthode entière.
Ce qu'on teste en POO : la construction de l'objet (__init__), chaque méthode publique isolément, les changements d'état internes, la levée d'exceptions métier, et les interactions entre plusieurs objets.
# banque.py ─────────────────────────────────────── class CompteBancaire: """Compte bancaire simple avec solde, dépôts et retraits.""" def __init__(self, titulaire: str, solde_initial: float = 0.0): if solde_initial < 0: raise ValueError("Le solde initial ne peut pas être négatif") self.titulaire = titulaire self._solde = solde_initial self._historique = [] @property def solde(self) -> float: return self._solde def deposer(self, montant: float) -> None: if montant <= 0: raise ValueError("Le montant doit être positif") self._solde += montant self._historique.append(("dépôt", montant)) def retirer(self, montant: float) -> None: if montant <= 0: raise ValueError("Le montant doit être positif") if montant > self._solde: raise RuntimeError(f"Solde insuffisant : {self._solde:.2f}€") self._solde -= montant self._historique.append(("retrait", montant)) def virement(self, destinataire: "CompteBancaire", montant: float) -> None: self.retirer(montant) # peut lever RuntimeError destinataire.deposer(montant) def nb_operations(self) -> int: return len(self._historique) def __repr__(self) -> str: return f"Compte({self.titulaire!r}, solde={self._solde:.2f}€)"
import unittest from banque import CompteBancaire class TestCompteBancaire(unittest.TestCase): # ── setUp : état propre avant chaque test ───────── def setUp(self): """Crée deux comptes réutilisables dans tous les tests.""" self.alice = CompteBancaire("Alice", solde_initial=500.0) self.bob = CompteBancaire("Bob", solde_initial=200.0) # ── Tests du constructeur ───────────────────────── def test_init_solde_correct(self): compte = CompteBancaire("Charlie", 100.0) self.assertEqual(compte.solde, 100.0) self.assertEqual(compte.titulaire, "Charlie") def test_init_solde_zero_par_defaut(self): compte = CompteBancaire("Dave") self.assertEqual(compte.solde, 0.0) def test_init_solde_negatif_interdit(self): with self.assertRaises(ValueError): CompteBancaire("Eve", solde_initial=-10) # ── Tests de dépôt ─────────────────────────────── def test_depot_augmente_solde(self): self.alice.deposer(100.0) self.assertEqual(self.alice.solde, 600.0) def test_depot_enregistre_historique(self): self.alice.deposer(50.0) self.assertEqual(self.alice.nb_operations(), 1) def test_depot_montant_zero_interdit(self): with self.assertRaises(ValueError): self.alice.deposer(0) def test_depot_montant_negatif_interdit(self): with self.assertRaises(ValueError): self.alice.deposer(-50) # ── Tests de retrait ───────────────────────────── def test_retrait_diminue_solde(self): self.alice.retirer(200.0) self.assertEqual(self.alice.solde, 300.0) def test_retrait_solde_insuffisant(self): with self.assertRaisesRegex(RuntimeError, "Solde insuffisant"): self.alice.retirer(600.0) # alice n'a que 500€ def test_retrait_solde_reste_inchange_si_echec(self): # Le solde ne doit PAS changer si le retrait échoue try: self.alice.retirer(9999) except RuntimeError: pass self.assertEqual(self.alice.solde, 500.0) # inchangé ! # ── Tests de virement (interaction 2 objets) ───── def test_virement_debit_emetteur(self): self.alice.virement(self.bob, 100.0) self.assertEqual(self.alice.solde, 400.0) def test_virement_credit_destinataire(self): self.alice.virement(self.bob, 100.0) self.assertEqual(self.bob.solde, 300.0) def test_virement_insuffisant_aucun_changement(self): # Si le virement échoue, les DEUX comptes restent intacts try: self.alice.virement(self.bob, 9999) except RuntimeError: pass self.assertEqual(self.alice.solde, 500.0) self.assertEqual(self.bob.solde, 200.0) # ── Test de l'état interne après séquence ──────── def test_sequence_operations(self): self.alice.deposer(100) self.alice.retirer(50) self.alice.deposer(200) self.assertEqual(self.alice.solde, 750.0) # 500+100-50+200 self.assertEqual(self.alice.nb_operations(), 3) # 3 opérations # ── Test de représentation ─────────────────────── def test_repr(self): self.assertIn("Alice", repr(self.alice)) self.assertIn("500.00", repr(self.alice))
Bonne pratique : Chaque méthode de test ne doit tester qu'une seule chose. Remarque que test_virement_debit_emetteur et test_virement_credit_destinataire sont séparés, même s'ils testent le même appel — l'un vérifie Alice, l'autre vérifie Bob.
pytest est le standard de facto dans l'industrie Python. Pas de classes obligatoires, assertions naturelles, plugins puissants, sortie lisible. Plus expressif et plus rapide à écrire.
Installation : pip install pytest — puis pytest pour lancer tous les tests découverts automatiquement.
🐌 Avec unittest
import unittest class TestAdd(unittest.TestCase): def test_addition(self): self.assertEqual(add(2,3), 5)
⚡ Avec pytest
# Pas de classe, pas d'import ! def test_addition(): assert add(2, 3) == 5
import pytest @pytest.mark.parametrize("entree, attendu", [ (2, True), (3, False), (0, True), (-4, True), ]) def test_est_pair(entree, attendu): assert est_pair(entree) == attendu # 4 tests générés # pytest.raises avec vérification du message def test_exception(): with pytest.raises(ValueError, match=r"zéro"): diviser(10, 0)
import pytest @pytest.fixture # scope="function" par défaut def utilisateur(): return {"nom": "Alice", "score": 0} @pytest.fixture def db(tmp_path): conn = init_db(tmp_path / "test.db") yield conn # ← setUp / tearDown en un seul bloc conn.close() @pytest.fixture(scope="module") # une fois par fichier def connexion_api(): return creer_connexion_test() # Injection automatique par nom def test_insertion(db, utilisateur): db.inserer(utilisateur) assert db.count() == 1
@pytest.mark.skip(reason="pas encore implémenté") def test_future(): pass @pytest.mark.xfail(reason="bug connu #123") def test_bug_connu(): assert calcul_bugge() == 42 @pytest.mark.slow def test_long(): # $ pytest -m "not slow" → exclut ce test pass
Avec pytest, les fixtures remplacent avantageusement le setUp. On peut grouper les tests d'une classe dans une class Test… sans hériter de TestCase — ce qui garde tous les avantages de pytest.
import pytest from banque import CompteBancaire # ── Fixtures partagées ─────────────────────────── @pytest.fixture def alice(): return CompteBancaire("Alice", solde_initial=500.0) @pytest.fixture def bob(): return CompteBancaire("Bob", solde_initial=200.0) # ── Classe de tests groupés (sans héritage) ────── class TestConstructeur: def test_solde_initial(self): c = CompteBancaire("Charlie", 100.0) assert c.solde == 100.0 assert c.titulaire == "Charlie" def test_solde_zero_par_defaut(self): assert CompteBancaire("Dave").solde == 0.0 def test_solde_negatif_interdit(self): with pytest.raises(ValueError): CompteBancaire("Eve", solde_initial=-10) class TestDepot: def test_augmente_solde(self, alice): alice.deposer(100.0) assert alice.solde == 600.0 def test_enregistre_historique(self, alice): alice.deposer(50.0) assert alice.nb_operations() == 1 @pytest.mark.parametrize("montant_invalide", [0, -1, -100]) def test_montant_invalide(self, alice, montant_invalide): with pytest.raises(ValueError): alice.deposer(montant_invalide) class TestRetrait: def test_diminue_solde(self, alice): alice.retirer(200.0) assert alice.solde == 300.0 def test_solde_insuffisant(self, alice): with pytest.raises(RuntimeError, match="Solde insuffisant"): alice.retirer(600.0) def test_solde_inchange_si_echec(self, alice): with pytest.raises(RuntimeError): alice.retirer(9999) assert alice.solde == 500.0 # inchangé class TestVirement: def test_debit_emetteur(self, alice, bob): alice.virement(bob, 100.0) assert alice.solde == 400.0 def test_credit_destinataire(self, alice, bob): alice.virement(bob, 100.0) assert bob.solde == 300.0 def test_echec_aucun_changement(self, alice, bob): with pytest.raises(RuntimeError): alice.virement(bob, 9999) assert alice.solde == 500.0 assert bob.solde == 200.0 # ── Test de séquence d'état ─────────────────────── def test_sequence_operations(alice): alice.deposer(100) alice.retirer(50) alice.deposer(200) assert alice.solde == 750.0 assert alice.nb_operations() == 3
Avantage des fixtures pytest pour la POO : chaque test reçoit un objet fraîchement instancié — pas de risque qu'un test modifie l'état et affecte le suivant. Avec setUp en unittest, ce risque existe si on oublie de réinitialiser un attribut.
| Critère | unittest | pytest |
|---|---|---|
| Installation | ✅ Standard Python | 📦 pip install pytest |
| Syntaxe | Classes + self.assertX() | Fonctions + assert natif |
| Fixtures | setUp/tearDown | @pytest.fixture (puissant) |
| Parametrize | subTest (limité) | @pytest.mark.parametrize |
| Messages d'erreur | Basiques | Détaillés avec diff intelligente |
| Plugins | Peu | Écosystème riche (cov, mock…) |
| Compatible unittest | — | ✅ Runner les classes TestCase |
Un mock est un objet factice qui imite une dépendance réelle (API, DB, fichier, GPIO...). Il permet de tester en isolation totale, de contrôler les effets de bord et de simuler des conditions difficiles à reproduire en vrai.
Cette section couvre les fondations, les mocks chaînés, le mocking de fichiers, les outils avancés (ANY, sentinel, call_args) et toutes les stratégies de patch pour isoler n'importe quelle dépendance.
MockObjet générique qui accepte tout. N'implémente pas les méthodes spéciales Python (__len__, __iter__...). À préférer quand tu contrôles tout manuellement.
MagicMockSous-classe de Mock qui implémente automatiquement les méthodes dunder. Utilisé par défaut dans patch(). Idéal pour les context managers, iterables, etc.
from unittest.mock import Mock, MagicMock m = Mock() mm = MagicMock() # Mock basique — configure le retour m.return_value = 42 assert m() == 42 # Mock — les méthodes spéciales échouent try: len(m) # → TypeError : Mock ne supporte pas __len__ except TypeError: pass # MagicMock — les méthodes spéciales fonctionnent mm.__len__.return_value = 5 assert len(mm) == 5 # ✅ mm.__iter__.return_value = iter([1, 2, 3]) assert list(mm) == [1, 2, 3] # ✅ # Context manager — seul MagicMock fonctionne naturellement with mm as ctx: ctx.lire() # ✅ pas d'erreur # spec= : limite aux attributs d'une vraie classe from datetime import datetime m_safe = MagicMock(spec=datetime) m_safe.year # ✅ datetime a .year # m_safe.inexistant # → AttributeError (protège les fautes de frappe)
patch()Règle d'or : Toujours patcher là où l'objet est utilisé, pas là où il est défini. Si mon_module.py fait from os import path, tu patches mon_module.path, pas os.path.
from unittest.mock import patch, MagicMock # ── 1. Context manager ──────────────────────────── def test_context_manager(): with patch("app.requests.get") as mock_get: mock_get.return_value.json.return_value = {"prix": 42.5} resultat = recuperer_prix("BTC") assert resultat == 42.5 # Hors du bloc : requests.get est restauré automatiquement # ── 2. Décorateur — injecté en paramètre (ordre inversé !) ── @patch("app.smtplib.SMTP") @patch("app.requests.get") def test_decorateur(mock_get, mock_smtp): # ATTENTION : le plus bas décorateur = premier paramètre mock_get.return_value.status_code = 200 envoyer_rapport() mock_smtp.return_value.sendmail.assert_called_once() # ── 3. Fixture pytest (recommandé avec pytest-mock) ────── def test_fixture(mocker): mock_db = mocker.patch("app.db.connexion") mock_db.return_value.query.return_value = ["alice", "bob"] assert lister_utilisateurs() == ["alice", "bob"] # Nettoyage automatique après le test, sans with ni décorateur
Quand le code enchaîne des appels (obj.attr.methode().resultat), un Mock crée automatiquement des enfants. Comprendre cette hiérarchie est essentiel pour configurer les bons retours.
Hiérarchie générée automatiquement par Mock :
from unittest.mock import patch, MagicMock, call # ── Exemple : code qui chaîne des appels ────────── # def recuperer_temperature(ville): # resp = requests.get(url) # return resp.json()["main"]["temp"] def test_chaine_simple(): with patch("meteo.requests.get") as mock_get: # Configurer la chaîne complète : mock_get.return_value.json.return_value = {"main": {"temp": 22.5}} temp = recuperer_temperature("Paris") assert temp == 22.5 # Vérifier la chaîne d'appels mock_get.assert_called_once_with("https://api.meteo.io/Paris") mock_get.return_value.json.assert_called_once() # ── Mocks chaînés plus profonds ─────────────────── # Code : db.session.query(User).filter_by(id=1).first() def test_chaine_orm(): with patch("app.db") as mock_db: mock_user = MagicMock(nom="Alice", age=30) mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_user user = trouver_utilisateur(id=1) assert user.nom == "Alice" # ── Plusieurs appels successifs (side_effect liste) ─ def test_appels_successifs(): with patch("app.lire_capteur") as mock_cap: # Simule 3 lectures consécutives du capteur mock_cap.side_effect = [23.1, 23.4, 23.2] valeurs = [lire_capteur() for _ in range(3)] assert sum(valeurs) / 3 == pytest.approx(23.23, abs=0.01) # ── Nested patches : plusieurs patches simultanés ─ @patch("app.envoyer_sms") @patch("app.envoyer_email") @patch("app.db.sauvegarder") def test_notification_complete(mock_save, mock_email, mock_sms): # Ordre : bas vers haut dans les décorateurs notifier_utilisateur("Alice", "Votre commande est prête") mock_save.assert_called_once() mock_email.assert_called_once_with("Alice", "Votre commande est prête") mock_sms.assert_called_once()
Tester du code qui lit ou écrit des fichiers sans créer de vrais fichiers sur le disque — essentiel pour des tests rapides, isolés et reproductibles.
from unittest.mock import patch, mock_open, MagicMock # ── Le code à tester ────────────────────────────── # def lire_config(chemin): # with open(chemin, "r") as f: # return json.load(f) # ── 1. mock_open : simuler open() ───────────────── def test_lire_config(): contenu_json = '{"host": "localhost", "port": 5432}' m = mock_open(read_data=contenu_json) with patch("builtins.open", m): config = lire_config("config.json") assert config["host"] == "localhost" assert config["port"] == 5432 m.assert_called_once_with("config.json", "r") # ── 2. mock_open en écriture ────────────────────── # def sauvegarder_log(message, chemin): # with open(chemin, "a") as f: # f.write(f"{datetime.now()} - {message}\n") def test_sauvegarder_log(): m = mock_open() with patch("builtins.open", m), patch("mon_module.datetime") as mock_dt: mock_dt.now.return_value.__str__.return_value = "2024-01-01 10:00:00" sauvegarder_log("Démarrage", "app.log") m.assert_called_with("app.log", "a") # Vérifier ce qui a été écrit handle = m.return_value.__enter__.return_value handle.write.assert_called_once_with("2024-01-01 10:00:00 - Démarrage\n") # ── 3. tmp_path (pytest) : vraie alternative propre ── def test_avec_tmp_path(tmp_path): # pytest crée un vrai répertoire temporaire → supprimé après le test fichier = tmp_path / "config.json" fichier.write_text('{"host": "db", "port": 5432}') config = lire_config(str(fichier)) assert config["port"] == 5432 # ── 4. Mocker os.path et os.listdir ─────────────── def test_lister_fichiers_log(): with patch("os.listdir", return_value=["app.log", "error.log", "data.csv"]), \ patch("os.path.isfile", return_value=True): logs = lister_fichiers_log("/var/log") assert len(logs) == 2 # seulement les .log assert "data.csv" not in logs
ANY, sentinel, call_argsfrom unittest.mock import Mock, MagicMock, call, ANY, sentinel, patch # ════════════════════════════════════════════════ # ANY — correspond à n'importe quelle valeur # ════════════════════════════════════════════════ def test_any(): m = Mock() m("Alice", age=30, timestamp=1704067200) # On veut vérifier le nom mais peu importe les autres args m.assert_called_once_with("Alice", age=ANY, timestamp=ANY) # ANY est égal à tout assert ANY == 42 assert ANY == "hello" assert ANY == None assert ANY == [1, 2, 3] def test_any_dans_liste(): m = Mock() m(1); m(2); m(3) # Vérifier uniquement le premier et dernier appel assert m.call_args_list == [call(1), call(ANY), call(3)] # ════════════════════════════════════════════════ # sentinel — créer des valeurs uniques et nommées # ════════════════════════════════════════════════ def test_sentinel(): # sentinel.NOM crée un objet unique (singleton) VIDE = sentinel.VIDE NON_INIT = sentinel.NON_INITIALISE # Utile pour distinguer "None" de "pas de valeur" def get_valeur(cle, defaut=sentinel.ABSENT): if defaut is sentinel.ABSENT: raise KeyError(f"Clé '{cle}' introuvable") return defaut assert get_valeur("x", None) is None # None est une valeur explicite with pytest.raises(KeyError): get_valeur("x") # sans défaut → exception # ════════════════════════════════════════════════ # call_args — inspecter les arguments d'appel # ════════════════════════════════════════════════ def test_call_args(): m = Mock() m(10, 20, label="test", actif=True) # call_args = (args, kwargs) du dernier appel args, kwargs = m.call_args assert args == (10, 20) assert kwargs["label"] == "test" assert kwargs["actif"] is True def test_call_args_list(): m = Mock() m("a", x=1) m("b", x=2) m("c") # Inspecter chaque appel individuellement premier_appel = m.call_args_list[0] assert premier_appel == call("a", x=1) # Extraire et tester un paramètre spécifique de chaque appel tous_x = [c.kwargs["x"] for c in m.call_args_list if "x" in c.kwargs] assert tous_x == [1, 2] # ════════════════════════════════════════════════ # call() — construire des appels attendus # ════════════════════════════════════════════════ def test_call_object(): m = Mock() m(1, 2) m("hello", key="val") # Comparer la séquence complète d'appels assert m.call_args_list == [ call(1, 2), call("hello", key="val"), ] # assert_has_calls : l'ordre peut être flexible m.assert_has_calls([call(1, 2), call("hello", key="val")]) m.assert_has_calls([call("hello", key="val")], any_order=True)
side_effect — comportements dynamiquesfrom unittest.mock import Mock m = Mock() # 1. Exception → levée à l'appel m.side_effect = ConnectionError("Réseau indisponible") # 2. Liste → valeurs retournées une par une m.side_effect = [23.1, 23.4, 23.2] # m() → 23.1, m() → 23.4, m() → 23.2, m() → StopIteration # 3. Mélange valeurs/exceptions m.side_effect = [10, IOError("Capteur déconnecté"), 12] # 1er appel → 10, 2ème → lève IOError, 3ème → 12 # 4. Fonction → logique conditionnelle complète def simuler_capteur(pin): if pin == 4: return 36.6 # capteur température elif pin == 17: return 0 # GPIO LOW raise ValueError(f"Pin {pin} inconnu") m.side_effect = simuler_capteur assert m(4) == 36.6 assert m(17) == 0
autospec et create_autospec — mocks sûrsfrom unittest.mock import create_autospec, patch class CapteurI2C: def __init__(self, adresse: int): self.adresse = adresse def lire_temperature(self) -> float: ... def calibrer(self, offset: float, unite: str = "C") -> None: ... # create_autospec : vérifie la signature à chaque appel mock_capteur = create_autospec(CapteurI2C, instance=True) mock_capteur.lire_temperature.return_value = 22.5 mock_capteur.lire_temperature() # ✅ OK # mock_capteur.lire_temperature(42) # → TypeError : trop d'args mock_capteur.calibrer(0.5, unite="F") # ✅ OK # mock_capteur.calibrer(0.5, "F", "X") # → TypeError # mock_capteur.inexistant # → AttributeError # Avec patch + autospec=True (recommandé en prod) @patch("app.CapteurI2C", autospec=True) def test_lecture_securisee(mock_cls): instance = mock_cls.return_value instance.lire_temperature.return_value = 21.0 systeme = SystemeAlerte() systeme.verifier_temperature() instance.lire_temperature.assert_called_once()
| Outil | Usage |
|---|---|
| Mock() | Objet générique — tout accepté |
| MagicMock() | Mock + méthodes dunder (__len__, __iter__…) |
| patch("module.obj") | Remplace temporairement un objet dans un module |
| patch.object(cls, "attr") | Patche un attribut d'une classe spécifique |
| mock_open(read_data=…) | Simule open() pour les tests de fichiers |
| return_value | Ce que retourne le mock quand appelé |
| side_effect | Exception, liste de retours, ou fonction |
| ANY | Placeholder qui matche n'importe quelle valeur |
| sentinel.NOM | Objet unique nommé (pour distinguer None d'"absent") |
| call_args / call_args_list | Inspecter les arguments des appels passés |
| call() | Construire un appel attendu pour comparaison |
| create_autospec(cls) | Mock qui vérifie la signature à chaque appel |
| assert_called_with() | Vérifie le dernier appel |
| assert_called_once_with() | Vérifie qu'un seul appel avec ces args a eu lieu |
| assert_has_calls([]) | Vérifie une séquence d'appels (ordre optionnel) |
| assert_not_called() | Vérifie que le mock n'a jamais été appelé |
| reset_mock() | Efface l'historique des appels |
Pour tester du matériel électronique (capteurs, GPIO, I2C, UART…), les mocks sont indispensables : on ne peut pas brûler un capteur ou dépendre d'un Raspberry Pi dans un pipeline CI. La page dédiée couvre tous ces cas.