Pytest est devenu en 2026 le standard de fait pour les tests Python. La version 9.0 stabilisée en novembre 2025 (9.0.2 en avril 2026) apporte la configuration native dans pyproject.toml, l’affichage de progression dans l’onglet du terminal compatible OSC 9;4, et un écosystème de plus de 1000 plugins répertoriés (1007 au printemps 2026). Pour quitter le stade « j’écris des assert simples » et bâtir une suite de tests sérieuse qui résiste à l’évolution d’un projet, plusieurs mécaniques avancées s’imposent : fixtures paramétrables et scopées, paramétrisation indirecte, marqueurs personnalisés, mocking ciblé, mesure de couverture et exécution parallèle. Ce tutoriel les enchaîne pas à pas avec des exemples directement testables.
Prérequis
- Python 3.13.13 ou 3.14.5 (cf. Installer Python 3 et son environnement)
- Un projet existant avec un
pyproject.tomlousetup.cfg - Notions de fonctions, classes et décorateurs Python
- Temps estimé : 90 minutes
Étape 1 — Installer pytest et configurer le projet
L’installation standard se fait via pip ou uv. Pour un projet structuré, on l’inscrit comme dépendance de développement plutôt que globale. Le fichier pyproject.toml centralise désormais toute la configuration pytest sous la table [tool.pytest.ini_options] (pour la rétrocompatibilité) ou [tool.pytest] en natif depuis 9.0.
# Avec uv (recommandé 2026)
uv add --dev pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
# Avec pip classique
pip install --upgrade pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
Cette ligne installe pytest 9.0.2 et les quatre plugins les plus utiles : cov pour la couverture, xdist pour l’exécution parallèle, mock pour les patches ciblés, et asyncio pour les tests de coroutines. Le résultat attendu : une suite de versions cohérentes dans le lock-file. Vérifiez avec pytest --version qui doit afficher pytest 9.0.2.
# pyproject.toml — configuration pytest moderne
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--strict-config",
"-ra",
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html"
]
markers = [
"slow: marque les tests lents (plus de 1 s)",
"integration: tests d'intégration nécessitant une base externe",
"smoke: tests de fumée minimaux"
]
Cette configuration impose trois disciplines structurantes. --strict-markers refuse tout marqueur non déclaré (évite les typos invisibles). --strict-config refuse les options inconnues dans la config. -ra affiche le résumé de tous les résultats sauf les passed. Les markers servent à filtrer une partie de la suite avec pytest -m slow ou exclure avec pytest -m "not slow".
Étape 2 — Fixtures avec scope et yield
Une fixture est une fonction décorée @pytest.fixture qui prépare un état avant un test et le nettoie après. Le scope détermine la durée de vie : function (défaut, recréée à chaque test), class, module, session (créée une seule fois pour toute la suite). Bien choisi, le scope évite des secondes inutiles à recréer une base ou une connexion HTTP entre chaque test.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def db_engine():
"""Une seule base SQLite en mémoire pour toute la session."""
engine = create_engine("sqlite:///:memory:")
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def db_session(db_engine):
"""Session SQLAlchemy isolée par test avec rollback automatique."""
connection = db_engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
Le yield sépare la phase setup (avant) de la phase teardown (après). Quand le test consommateur termine, pytest reprend l’exécution après yield pour fermer connexions, supprimer fichiers temporaires, restaurer variables d’environnement. Cette double phase remplace l’ancien couple setup/teardown de unittest et reste lisible même quand on imbrique plusieurs fixtures.
Les fixtures se composent par dépendance : db_session demande db_engine en paramètre, pytest résout la chaîne automatiquement. Pour partager des fixtures entre plusieurs fichiers de test, on les déclare dans tests/conftest.py qui est chargé automatiquement par pytest.
Étape 3 — Paramétrisation directe et indirecte
Tester la même logique avec plusieurs jeux de données évite la duplication. @pytest.mark.parametrize fournit une liste de valeurs et pytest génère un test par entrée, avec un identifiant clair dans la sortie. La paramétrisation indirecte via indirect=True permet de passer le paramètre à une fixture intermédiaire pour des transformations plus riches.
@pytest.mark.parametrize("entree,attendu", [
(0, 0), (1, 1), (2, 1), (3, 2), (10, 55), (15, 610),
], ids=["zero", "un", "deux", "trois", "dix", "quinze"])
def test_fibonacci(entree, attendu):
assert fibonacci(entree) == attendu
@pytest.fixture
def utilisateur(request):
role = request.param
return Utilisateur(nom=f"test_{role}", role=role)
@pytest.mark.parametrize("utilisateur", ["admin", "editeur", "lecteur"], indirect=True)
def test_permissions(utilisateur):
assert utilisateur.role in {"admin", "editeur", "lecteur"}
Le paramètre ids donne des noms lisibles aux cas dans la sortie pytest. Sans ids, pytest génère des IDs basés sur les valeurs (utile pour des entrées simples, vite illisible pour des dicts). Pour des paramètres complexes, toujours fournir des IDs explicites. La paramétrisation indirecte est puissante mais demande un peu de mécanique mentale : le paramètre va à la fixture, pas au test.
Étape 4 — Mocking ciblé avec pytest-mock
Isoler la logique testée de ses dépendances externes (API, base de données, horloge système) passe par le mocking. La bibliothèque standard unittest.mock fonctionne, mais pytest-mock expose une fixture mocker qui s’intègre nativement au cycle de vie pytest : le patch est automatiquement annulé en fin de test, sans gestionnaire de contexte manuel.
def test_envoie_email_appelle_provider(mocker):
fake_send = mocker.patch("monpkg.notifications.send_via_smtp")
fake_send.return_value = True
resultat = envoyer_bienvenue("user@example.com")
assert resultat is True
fake_send.assert_called_once_with(
destinataire="user@example.com",
sujet="Bienvenue",
corps=mocker.ANY
)
def test_lit_heure_courante(mocker):
mocker.patch("monpkg.time.now", return_value=datetime(2026, 5, 17, 12, 0, 0))
assert horodatage_courant() == "2026-05-17 12:00"
Trois points pratiques. mocker.patch("chemin.complet.fonction") remplace la fonction à l’endroit où elle est importée, pas où elle est définie. assert_called_once_with vérifie à la fois le nombre d’appels et les arguments exacts. mocker.ANY tolère les arguments qu’on ne veut pas asserter strictement.
Étape 5 — Couverture avec pytest-cov
La mesure de couverture indique quelles lignes ont été exécutées par les tests. Combinée à des seuils minimaux en CI, elle prévient les régressions de couverture insidieuses. pytest-cov intègre coverage.py et génère plusieurs formats de rapport : terminal, HTML navigable, XML pour les outils CI.
pytest --cov=src --cov-report=term-missing --cov-report=html
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/tests/*", "*/__init__.py"]
[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 80
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:"
]
branch = true mesure aussi la couverture des branches (un if doit être testé sur ses deux sorties). fail_under = 80 fait échouer la CI sous 80 % — chiffre cible raisonnable. exclude_lines retire les patterns qui ne valent pas la peine d’être testés. Le rapport HTML (htmlcov/index.html) montre ligne par ligne ce qui n’est pas couvert.
Étape 6 — Tests asynchrones et exécution parallèle
Tester des coroutines async def demande pytest-asyncio. Depuis sa version 0.21, le mode auto détecte les coroutines et leur applique @pytest.mark.asyncio automatiquement.
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
async def test_fetch_user(http_client):
response = await http_client.get("/users/42")
assert response.status_code == 200
assert response.json()["id"] == 42
@pytest_asyncio.fixture
async def http_client():
async with httpx.AsyncClient(base_url="http://test") as client:
yield client
Pour les suites longues, pytest-xdist distribue les tests sur plusieurs workers : pytest -n auto utilise un worker par cœur CPU disponible. Sur une suite de 500 tests qui dure 60 secondes en mono-thread, on tombe à 12-15 secondes sur un Mac M2 à 8 cœurs. Les fixtures de scope session sont créées par worker, pas globalement.
Étape 7 — Intégrer pytest en CI GitHub Actions
Le workflow CI typique : checkout, installation Python, installation des dépendances, lancement pytest avec rapport JUnit XML, upload de la couverture vers Codecov. Le tout en moins de deux minutes pour une suite raisonnable.
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v8.1.0
- run: uv python install ${{ matrix.python }}
- run: uv sync --all-extras
- run: uv run pytest --junit-xml=junit.xml --cov-report=xml -n auto
- uses: codecov/codecov-action@v6
with:
files: ./coverage.xml
La matrice python: ["3.13", "3.14"] exécute la suite sur les deux versions stables. setup-uv est l’action officielle Astral qui installe uv en quelques secondes. Le token Codecov est optionnel pour les dépôts publics.
Étape 8 — Marqueurs personnalisés et hooks
Au-delà des marqueurs déclaratifs, pytest expose un système de hooks puissant pour injecter du comportement avant/après chaque test, modifier la collection, ou enrichir le rapport. Les hooks vivent dans conftest.py.
# tests/conftest.py
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.duration > 1.0:
print(f"\nTEST LENT : {item.nodeid} a pris {rep.duration:.2f}s")
def pytest_collection_modifyitems(config, items):
if not os.getenv("DATABASE_URL"):
skip_marker = pytest.mark.skip(reason="DATABASE_URL non défini")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip_marker)
Le premier hook trace les tests lents en temps réel. Le second skippe automatiquement les tests d’intégration si la variable d’environnement requise est absente, ce qui rend la suite portable entre laptop local et CI.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| ModuleNotFoundError sur l’import | Pas de __init__.py ou racine pas dans sys.path | Layout src/ avec pip install -e . |
| Mock qui ne fonctionne pas | Patch au lieu de définition, pas d’import | Patcher monpkg.module_qui_utilise.fonction |
| Fixture scope=session qui meurt | xdist crée une session par worker | Sortir l’état partagé ou désactiver xdist |
| Test async qui ne s’exécute pas | Mode asyncio non configuré | asyncio_mode = « auto » |
| Couverture qui chute | Code branché non testé | branch = true dans coverage.run |
| Marqueur ignoré (typo) | strict-markers non actif | Ajouter –strict-markers |
Foire aux questions
pytest ou unittest ?
pytest pour tout nouveau projet. unittest reste utile pour intégrer du code historique ou pour la stricte stdlib sans dépendance externe.
Quelle couverture viser ?
80 % sur les couches métier et utilitaires, 60-70 % sur la couche d’intégration. 100 % est rarement utile.
Comment exécuter un seul test ?pytest tests/test_user.py::test_creation_admin ou pytest -k "admin" pour filtrer par mot-clé.
Faut-il versionner .coverage ?
Non. Ajouter .coverage et htmlcov/ au .gitignore.
Comment tester un endpoint FastAPI ?httpx.AsyncClient avec app=fastapi_app ou TestClient de Starlette.
Comment éviter les tests flaky ?
Bannir time.sleep au profit de freezegun ou mocks d’horloge. Cassettes VCR pour le réseau.
Combien de tests par fichier ?
Un fichier par module testé. Si un fichier dépasse 500 lignes, le découper.
Pour aller plus loin
Avec une suite pytest robuste en place, l’étape logique suivante est asyncio en production pour les services concurrents, ou Pydantic v2 pour la validation de schémas. Pour la vue d’ensemble de l’écosystème Python moderne, voir le guide principal Python.