Tests unitaires Python avec pytest
Apprenez pytest de zéro : assertions, fixtures, paramétrisation et organisation d'une suite de tests avec conftest.py.
pytest est le framework de test le plus populaire de Python. Il vous permet d'écrire des fonctions de test petites et lisibles à l'aide de simples instructions assert — sans classes boilerplate requises — tout en s'adaptant à des suites de tests complexes avec des fixtures partagées, la paramétrisation et des plugins.
Ce chapitre couvre tout ce dont vous avez besoin pour tester du code Python avec pytest : installation, écriture de votre premier test, assertions et exceptions attendues, fixtures, parametrize, organisation des tests avec conftest.py, options de ligne de commande utiles et les pièges les plus courants.
Pourquoi pytest ?
Python est livré avec le module unittest, alors pourquoi utiliser pytest à la place ?
| Fonctionnalité | unittest | pytest |
|---|---|---|
| Syntaxe des tests | Classe + méthode | Fonction simple |
| Assertions | self.assertEqual(a, b) | assert a == b |
| Fixtures | setUp / tearDown | @pytest.fixture (composable) |
| Paramétrisation | Boucle manuelle | @pytest.mark.parametrize |
| Écosystème de plugins | Minimal | 1 000+ plugins (coverage, mock, etc.) |
pytest exécute également les tests de style unittest sans modification, ce qui vous permet de l'adopter progressivement.
Installation
pytest ne fait pas partie de la bibliothèque standard. Installez-le avec pip dans un environnement virtuel :
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytestVérifiez l'installation :
pytest --version
# pytest 8.x.xConsultez Python pip si vous avez besoin d'un rappel sur la gestion des packages.
Votre premier test
pytest découvre les fichiers de test automatiquement. Par défaut, il recherche :
- Les fichiers nommés
test_*.pyou*_test.py - Les fonctions dont le nom commence par
test_
Créez math_utils.py avec une fonction simple :
# math_utils.py
def add(a, b):
return a + bCréez maintenant test_math_utils.py dans le même répertoire :
# test_math_utils.py
from math_utils import add
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, 1) == 0
def test_add_zeros():
assert add(0, 0) == 0Exécutez les tests :
pytest test_math_utils.pySortie :
collected 3 items
test_math_utils.py ... [100%]
3 passed in 0.01sChaque point représente un test réussi. Un test échoué affiche F et montre la différence complète de l'assertion.
Assertions
pytest réécrit les instructions assert ordinaires au moment de la collecte afin que les échecs montrent une différence détaillée — plus besoin de méthodes d'assertion spéciales.
def test_assertion_diff():
result = [1, 2, 4]
expected = [1, 2, 3]
assert result == expected # pytest shows exactly where lists differUne sortie d'échec ressemble à :
AssertionError: assert [1, 2, 4] == [1, 2, 3]
At index 2: 4 != 3Comparaisons en virgule flottante
Ne comparez jamais des flottants avec == — les erreurs d'arrondi rendent cela peu fiable. Utilisez pytest.approx :
import pytest
import math
def circle_area(r):
return math.pi * r * r
def test_circle_area():
assert circle_area(5) == pytest.approx(78.53981633974483)pytest.approx accepte une tolérance optionnelle abs ou rel :
assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)Tester les exceptions attendues
Utilisez pytest.raises comme gestionnaire de contexte pour vérifier qu'une exception spécifique est levée :
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)L'argument match est une expression régulière vérifiée par rapport au message d'exception. Si l'exception n'est pas levée, pytest échoue au test — vous assurant ainsi de détecter les régressions où la gestion des erreurs est accidentellement supprimée.
Consultez Python Try...Except pour approfondir la gestion des exceptions, et Raising Exceptions pour savoir comment les lever intentionnellement.
Parametrize : exécuter un test avec de nombreuses entrées
@pytest.mark.parametrize vous permet d'exécuter la même logique de test sur plusieurs jeux de données sans écrire de boucle :
import pytest
from math_utils import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expectedpytest génère un cas de test distinct pour chaque tuple et les rapporte individuellement :
test_math_utils.py::test_add[2-3-5] PASSED
test_math_utils.py::test_add[-1-1-0] PASSED
test_math_utils.py::test_add[0-0-0] PASSED
test_math_utils.py::test_add[10--5-5] PASSEDC'est bien plus propre qu'une boucle manuelle — les échecs individuels sont isolés et faciles à identifier.
Fixtures
Une fixture est une fonction décorée avec @pytest.fixture qui fournit une configuration partagée (et un démontage optionnel) pour les tests. Au lieu de répéter le code de configuration dans chaque test, vous déclarez une fixture une fois et l'injectez par nom en tant que paramètre de test.
Fixture de base
import pytest
class UserStore:
def __init__(self):
self.users = []
def add_user(self, name):
self.users.append(name)
def count(self):
return len(self.users)
@pytest.fixture
def store():
return UserStore()
def test_empty_store(store):
assert store.count() == 0
def test_add_user(store):
store.add_user("Alice")
assert store.count() == 1pytest voit que test_add_user a un paramètre appelé store, cherche une fixture avec ce nom, l'appelle et transmet le résultat. Chaque test obtient une instance de fixture fraîche — les modifications dans un test ne se propagent jamais à un autre.
Fixtures avec démontage (yield)
Utilisez yield dans une fixture pour la diviser en configuration (avant yield) et démontage (après yield). Cela garantit que le nettoyage s'exécute toujours, même si le test échoue :
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
fd, path = tempfile.mkstemp(suffix=".txt")
os.close(fd)
yield path # test receives the path here
if os.path.exists(path):
os.unlink(path) # always runs after the test
def test_write_to_temp_file(temp_file):
with open(temp_file, "w") as f:
f.write("hello")
with open(temp_file) as f:
assert f.read() == "hello"Portée des fixtures
Par défaut, les fixtures sont créées et démontées une fois par fonction de test. Vous pouvez élargir la portée pour réduire les configurations coûteuses :
| Portée | Créée une fois par |
|---|---|
"function" (défaut) | Chaque fonction de test |
"class" | Chaque classe de test |
"module" | Chaque fichier de test |
"session" | Toute l'exécution des tests |
@pytest.fixture(scope="session")
def database_connection():
conn = create_db_connection()
yield conn
conn.close()Utilisez la portée "session" pour les ressources coûteuses comme les connexions à une base de données ou les processus serveur. Utilisez la portée "function" (le défaut) pour tout ce qui modifie un état.
Fixtures intégrées
pytest est livré avec plusieurs fixtures intégrées que vous pouvez utiliser sans rien importer :
tmp_path— unpathlib.Pathpointant vers un répertoire temporaire unique au test.monkeypatch— remplace des attributs, des variables d'environnement ou des entrées de dictionnaire pendant la durée d'un test, puis les restaure automatiquement.capsys— capture la sortiestdout/stderrafin que vous puissiez faire des assertions sur le texte affiché.
def greet(name):
print(f"Hello, {name}!")
def test_greet_output(capsys):
greet("World")
captured = capsys.readouterr()
assert captured.out == "Hello, World!\n"Utiliser monkeypatch
monkeypatch est la façon idiomatique de remplacer les dépendances externes dans les tests sans bibliothèque mock tierce :
import time
def get_timestamp():
return time.time()
def test_get_timestamp(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 1_000_000.0)
assert get_timestamp() == 1_000_000.0Après le test, time.time est restauré à son implémentation d'origine. Consultez Python Decorators si vous voulez comprendre comment @pytest.fixture fonctionne en coulisses.
Organiser les tests avec conftest.py
Lorsqu'une fixture est nécessaire pour des tests dans plusieurs fichiers, placez-la dans conftest.py. pytest découvre les fichiers conftest.py automatiquement et rend leurs fixtures disponibles pour tous les tests dans le même répertoire et en dessous — aucune importation n'est requise.
project/
├── conftest.py # shared fixtures live here
├── test_users.py
├── test_orders.py
└── utils/
├── conftest.py # fixtures scoped to this subdirectory
└── test_helpers.py# conftest.py
import pytest
@pytest.fixture
def admin_user():
return {"name": "Admin", "role": "admin", "active": True}# test_users.py — no import needed; pytest injects admin_user automatically
def test_admin_is_active(admin_user):
assert admin_user["active"] is TrueTests basés sur des classes
Vous pouvez regrouper des tests connexes dans une classe. Contrairement à unittest.TestCase, les classes pytest n'exigent pas d'héritage :
class TestCalculator:
def test_add(self):
assert 2 + 2 == 4
def test_multiply(self):
assert 3 * 4 == 12
def test_subtract(self):
assert 10 - 3 == 7Les classes sont utiles pour regrouper des tests qui partagent une préoccupation logique. Évitez les classes lorsque le regroupement n'a pas de réel avantage — les fonctions plates sont plus simples.
Marks : ignorer des tests et étiquettes personnalisées
Le système de marks de pytest vous permet d'annoter les tests avec des métadonnées pour une exécution sélective.
Ignorer un test
import pytest
import sys
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
assert False
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_feature():
assert TrueMarks personnalisées
Enregistrez des marks personnalisées dans pytest.ini (ou pyproject.toml) pour étiqueter les tests par catégorie :
# pytest.ini
[pytest]
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests@pytest.mark.slow
def test_large_dataset():
...Exécutez uniquement les tests lents :
pytest -m slowExécutez tout sauf les tests lents :
pytest -m "not slow"Options de ligne de commande utiles
pytest # run all discovered tests
pytest test_math_utils.py # run a specific file
pytest test_math_utils.py::test_add # run one test by name
pytest -v # verbose: show each test name
pytest -x # stop on first failure
pytest --tb=short # shorter traceback (default is long)
pytest -k "add" # run tests whose name contains "add"
pytest --lf # re-run only last-failing tests
pytest -q # quiet: minimal outputCouverture de code
Installez le plugin de couverture pour mesurer quelles lignes vos tests exercent :
pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missingLa sortie ajoute une colonne de couverture indiquant les lignes non couvertes :
Name Stmts Miss Cover Missing
---------------------------------------------
math_utils.py 2 0 100%Visez une couverture élevée pour la logique métier critique, mais ne cherchez pas à atteindre 100% — tester des accesseurs triviaux ajoute souvent du bruit sans valeur.
Pièges courants
1. Fixture introuvable. Si pytest signale fixture 'foo' not found, vérifiez que la fixture se trouve dans conftest.py ou dans le même fichier, et que la fonction est décorée avec @pytest.fixture.
2. Erreurs d'importation au moment de la collecte. Si pytest ne peut pas importer votre module, il renvoie une erreur avant d'exécuter un test. Exécutez python -c "import your_module" pour diagnostiquer.
3. Arguments par défaut mutables dans les fixtures. Tout comme les fonctions Python ordinaires, les fixtures doivent éviter les arguments par défaut mutables. Utilisez la portée "function" (le défaut) pour toute fixture qui construit un objet mutable.
4. assert dans les fonctions d'aide. Si vous appelez une fonction d'aide depuis un test et que cette fonction contient assert, assurez-vous que son nom commence par assert_ (convention pytest) afin que pytest réécrive l'assertion pour un meilleur message d'erreur.
5. Mélanger unittest.TestCase et les fixtures pytest. pytest exécute les tests unittest.TestCase, mais vous ne pouvez pas injecter des fixtures pytest dans les méthodes TestCase. Utilisez des classes de style pytest ou les méthodes de configuration unittest — pas les deux à la fois.