Pourquoi pytest (et pas unittest)
Python a unittest dans la stdlib. Alors pourquoi tout le monde utilise pytest ?
# unittest — verbeux, orienté classe
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(self.calc.add(-1, 1), 0)
if __name__ == '__main__':
unittest.main()
# pytest — concis, pythonique
def test_add():
calc = Calculator()
assert calc.add(2, 3) == 5
def test_add_negative():
calc = Calculator()
assert calc.add(-1, 1) == 0
Avantages de pytest :
assertsimple — Pas deself.assertEqual,self.assertIn,self.assertRaises…- Messages d’erreur détaillés — pytest décompose l’assertion pour montrer les valeurs
- Fixtures — Système de dépendance injection puissant
- Plugins — Un écosystème riche (coverage, mock, asyncio, django, etc.)
- Parametrize — Tester plusieurs cas en une ligne
- Compatible unittest — Tu peux migrer progressivement
Installation et configuration
pip install pytest pytest-cov pytest-mock pytest-xdist
Configuration dans pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v", # Verbose
"--tb=short", # Traceback court
"--strict-markers", # Erreur si marker non déclaré
"-ra", # Résumé des tests non-passed
]
markers = [
"slow: Tests lents (déactivés par défaut)",
"integration: Tests d'intégration",
"e2e: Tests end-to-end",
]
filterwarnings = [
"error", # Les warnings deviennent des erreurs
"ignore::DeprecationWarning",
]
Structure de projet
mon-projet/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── models.py
│ ├── services.py
│ └── utils.py
├── tests/
│ ├── conftest.py # Fixtures partagées
│ ├── unit/
│ │ ├── conftest.py # Fixtures unit-specific
│ │ ├── test_models.py
│ │ ├── test_services.py
│ │ └── test_utils.py
│ ├── integration/
│ │ ├── conftest.py
│ │ └── test_database.py
│ └── e2e/
│ └── test_api.py
├── pyproject.toml
└── requirements.txt
Les bases
Écrire un test
# tests/unit/test_utils.py
from myapp.utils import slugify, truncate
def test_slugify_simple():
assert slugify("Hello World") == "hello-world"
def test_slugify_special_chars():
assert slugify("Café résumé") == "cafe-resume"
def test_slugify_multiple_spaces():
assert slugify("hello world") == "hello-world"
def test_truncate_short_string():
assert truncate("hello", 10) == "hello"
def test_truncate_long_string():
assert truncate("hello world", 5) == "hello..."
def test_truncate_exact_length():
assert truncate("hello", 5) == "hello"
Lancer les tests
# Tous les tests
pytest
# Un fichier spécifique
pytest tests/unit/test_utils.py
# Un test spécifique
pytest tests/unit/test_utils.py::test_slugify_simple
# Tests matchant un pattern
pytest -k "slugify"
# Tests avec un marker
pytest -m "not slow"
# Avec verbose
pytest -v
# Stopper au premier échec
pytest -x
# Relancer seulement les tests échoués
pytest --lf
# Montrer les prints
pytest -s
Messages d’erreur intelligents
def test_list_comparison():
expected = [1, 2, 3, 4, 5]
actual = [1, 2, 4, 4, 5]
assert actual == expected
pytest affiche :
E AssertionError: assert [1, 2, 4, 4, 5] == [1, 2, 3, 4, 5]
E At index 2 diff: 4 != 3
E Full diff:
E - [1, 2, 3, 4, 5]
E + [1, 2, 4, 4, 5]
E ? ^
Pas besoin de assertEqual ou assertListEqual. Un simple assert suffit et pytest fait le travail d’introspection.
Fixtures : l’injection de dépendances
Les fixtures sont LE concept central de pytest. C’est un système d’injection de dépendances pour les tests.
Fixture basique
# tests/conftest.py
import pytest
@pytest.fixture
def calculator():
"""Fournit une instance de Calculator pour chaque test."""
return Calculator()
@pytest.fixture
def sample_user():
"""Fournit un utilisateur de test."""
return User(name="John", email="john@example.com", age=30)
# tests/unit/test_calculator.py
def test_add(calculator): # ← pytest injecte automatiquement
assert calculator.add(2, 3) == 5
def test_subtract(calculator):
assert calculator.subtract(5, 3) == 2
pytest voit que test_add demande un paramètre calculator, cherche une fixture avec ce nom, et l’injecte. Pas de setUp, pas de classe, juste des fonctions.
Scopes de fixtures
@pytest.fixture(scope="function") # Défaut — recréé pour chaque test
def fresh_db():
db = create_database()
yield db
db.drop()
@pytest.fixture(scope="class") # Partagé entre les tests d'une classe
def api_client():
return APIClient()
@pytest.fixture(scope="module") # Partagé dans tout le fichier
def config():
return load_config("test")
@pytest.fixture(scope="session") # Partagé pour toute la session pytest
def docker_services():
compose = start_docker_compose()
yield compose
compose.stop()
Setup et teardown avec yield
@pytest.fixture
def db_session():
"""Crée une session DB, rollback après le test."""
session = SessionLocal()
session.begin_nested() # Savepoint
yield session # ← Le test reçoit la session ici
# Teardown — exécuté après le test
session.rollback()
session.close()
Tout ce qui est avant yield = setup. Tout ce qui est après = teardown. Le teardown s’exécute même si le test échoue.
Fixtures qui dépendent d’autres fixtures
@pytest.fixture
def db_engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine): # ← Dépend de db_engine
Session = sessionmaker(bind=db_engine)
session = Session()
yield session
session.close()
@pytest.fixture
def user_service(db_session): # ← Dépend de db_session
return UserService(db_session)
pytest résout l’arbre de dépendances automatiquement.
conftest.py : fixtures partagées
Les fichiers conftest.py sont automatiquement chargés par pytest. Les fixtures définies dedans sont disponibles pour tous les tests du même répertoire et ses sous-répertoires.
tests/
├── conftest.py # Fixtures disponibles partout
├── unit/
│ ├── conftest.py # Fixtures disponibles dans unit/ seulement
│ └── test_services.py
└── integration/
├── conftest.py # Fixtures disponibles dans integration/ seulement
└── test_database.py
autouse : fixtures automatiques
@pytest.fixture(autouse=True)
def reset_environment():
"""Reset les variables d'env avant chaque test."""
original = os.environ.copy()
yield
os.environ.clear()
os.environ.update(original)
autouse=True applique la fixture à tous les tests dans le scope, sans qu’ils la demandent explicitement. Utilise avec parcimonie.
Parametrize : tester plusieurs cas
Basique
@pytest.mark.parametrize("input,expected", [
("hello", "hello"),
("Hello World", "hello-world"),
("Café résumé", "cafe-resume"),
(" spaces ", "spaces"),
("UPPERCASE", "uppercase"),
("", ""),
])
def test_slugify(input, expected):
assert slugify(input) == expected
Un seul test, 6 cas. Chaque cas apparaît séparément dans les résultats :
test_utils.py::test_slugify[hello-hello] PASSED
test_utils.py::test_slugify[Hello World-hello-world] PASSED
test_utils.py::test_slugify[Café résumé-cafe-resume] PASSED
...
IDs personnalisés
@pytest.mark.parametrize("price,discount,expected", [
pytest.param(100, 0, 100, id="no-discount"),
pytest.param(100, 10, 90, id="10-percent"),
pytest.param(100, 50, 50, id="half-price"),
pytest.param(200, 100, 100, id="100-off-200"),
])
def test_apply_discount(price, discount, expected):
assert apply_discount(price, discount) == expected
Combinaison de parametrize
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert multiply(x, y) == x * y
# Génère 6 tests : (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)
Parametrize avec exceptions
@pytest.mark.parametrize("input,expected_error", [
(-1, ValueError),
("abc", TypeError),
(None, TypeError),
])
def test_square_root_errors(input, expected_error):
with pytest.raises(expected_error):
square_root(input)
Mocking avec pytest-mock
Le mocking remplace des dépendances par des faux objets pour isoler le code testé.
Le fixture mocker
# pip install pytest-mock
def test_send_email(mocker):
# Mock le service d'email
mock_smtp = mocker.patch("myapp.services.smtplib.SMTP")
# Exécute le code
send_welcome_email("user@example.com")
# Vérifie que SMTP a été appelé
mock_smtp.assert_called_once()
mock_smtp.return_value.sendmail.assert_called_once()
Mocker une fonction
def test_get_weather(mocker):
# Mock l'appel HTTP
mock_get = mocker.patch("myapp.weather.requests.get")
mock_get.return_value.json.return_value = {
"temperature": 22,
"condition": "sunny"
}
mock_get.return_value.status_code = 200
result = get_weather("Paris")
assert result["temperature"] == 22
mock_get.assert_called_once_with(
"https://api.weather.com/v1/current",
params={"city": "Paris"}
)
Mocker un attribut
def test_premium_user(mocker):
user = User(name="John")
mocker.patch.object(user, "is_premium", return_value=True)
assert get_discount(user) == 20 # Les premium ont 20% de réduction
Side effects
def test_retry_on_failure(mocker):
mock_api = mocker.patch("myapp.client.api_call")
# Échoue 2 fois, réussit la 3ème
mock_api.side_effect = [
ConnectionError("timeout"),
ConnectionError("timeout"),
{"status": "ok"},
]
result = resilient_api_call()
assert result == {"status": "ok"}
assert mock_api.call_count == 3
MagicMock en détail
def test_database_operations(mocker):
mock_db = mocker.MagicMock()
# Configure le mock
mock_db.query.return_value.filter.return_value.first.return_value = User(
id=1, name="John"
)
# Le code utilise le mock comme une vraie DB
service = UserService(mock_db)
user = service.get_by_id(1)
assert user.name == "John"
mock_db.query.assert_called_once_with(User)
Mocker datetime
Pattern classique pour tester du code qui dépend du temps :
from freezegun import freeze_time
@freeze_time("2026-03-21 14:00:00")
def test_greeting_afternoon():
assert get_greeting() == "Bon après-midi"
@freeze_time("2026-03-21 08:00:00")
def test_greeting_morning():
assert get_greeting() == "Bonjour"
Ou avec mocker :
def test_greeting_afternoon(mocker):
mock_now = mocker.patch("myapp.utils.datetime")
mock_now.now.return_value = datetime(2026, 3, 21, 14, 0, 0)
assert get_greeting() == "Bon après-midi"
Markers : catégoriser les tests
Markers custom
# Déclaration dans pyproject.toml (voir plus haut)
# Utilisation
@pytest.mark.slow
def test_heavy_computation():
result = compute_all_primes(1_000_000)
assert len(result) > 0
@pytest.mark.integration
def test_database_connection(db_session):
assert db_session.execute("SELECT 1").scalar() == 1
# Exclure les tests lents
pytest -m "not slow"
# Seulement les tests d'intégration
pytest -m "integration"
# Combinaisons
pytest -m "not slow and not e2e"
Markers built-in
# Skip — ignorer un test
@pytest.mark.skip(reason="Feature pas encore implémentée")
def test_future_feature():
pass
# Skipif — ignorer conditionnellement
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_specific():
pass
# Xfail — test qui devrait échouer
@pytest.mark.xfail(reason="Bug #1234 pas encore fixé")
def test_known_bug():
assert broken_function() == "expected" # Échouera mais ne cassera pas le CI
Tester les exceptions
# Vérifier qu'une exception est levée
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
# Vérifier le message
def test_invalid_email():
with pytest.raises(ValueError, match="Email invalide"):
validate_email("not-an-email")
# Vérifier les attributs de l'exception
def test_http_error():
with pytest.raises(HTTPError) as exc_info:
api_call("/not-found")
assert exc_info.value.status_code == 404
assert "Not Found" in str(exc_info.value)
Tester du code async
# pip install pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://api.example.com/data")
assert result["status"] == "ok"
@pytest.mark.asyncio
async def test_async_with_mock(mocker):
mock_fetch = mocker.patch("myapp.client.aiohttp.ClientSession.get")
mock_response = mocker.AsyncMock()
mock_response.json.return_value = {"data": "test"}
mock_response.status = 200
mock_fetch.return_value.__aenter__.return_value = mock_response
result = await fetch_data("https://api.example.com")
assert result == {"data": "test"}
Coverage : mesurer la couverture
Configuration
# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*"]
branch = true # Couverture des branches (if/else)
[tool.coverage.report]
show_missing = true
skip_empty = true
fail_under = 80 # Échoue si < 80%
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
Lancer avec coverage
# Terminal
pytest --cov=src --cov-report=term-missing
# HTML (pour visualiser dans le navigateur)
pytest --cov=src --cov-report=html
open htmlcov/index.html
# XML (pour le CI)
pytest --cov=src --cov-report=xml
# Combiné
pytest --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml
Rapport terminal
---------- coverage: platform linux, python 3.12.0 ----------
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------------
src/myapp/__init__.py 0 0 0 0 100%
src/myapp/models.py 45 3 8 2 91% 34, 67-68
src/myapp/services.py 82 12 20 5 83% 45-56, 89
src/myapp/utils.py 30 0 12 0 100%
------------------------------------------------------------------------
TOTAL 157 15 40 7 89%
Ignorer du code
def debug_info(): # pragma: no cover
"""Fonction de debug, pas besoin de tester."""
return {
"version": __version__,
"python": sys.version,
}
if TYPE_CHECKING: # Automatiquement exclu
from myapp.types import Config
Plugins utiles
pytest-xdist : paralléliser les tests
pip install pytest-xdist
# Lancer sur 4 workers
pytest -n 4
# Auto-détecter le nombre de CPU
pytest -n auto
⚠️ Les tests doivent être indépendants pour la parallélisation.
pytest-randomly : ordre aléatoire
pip install pytest-randomly
# Les tests s'exécutent dans un ordre aléatoire
pytest # L'ordre change à chaque run
# Reproduire un ordre spécifique
pytest -p randomly --randomly-seed=12345
Détecte les tests qui dépendent de l’ordre d’exécution.
pytest-timeout : limiter le temps
pip install pytest-timeout
@pytest.mark.timeout(5) # Max 5 secondes
def test_api_call():
result = slow_api_call()
assert result is not None
pytest-sugar : sortie plus jolie
pip install pytest-sugar
# Automatiquement activé — barre de progression, couleurs
Patterns avancés
Factory fixtures
@pytest.fixture
def make_user():
"""Factory pour créer des users avec des valeurs par défaut."""
created_users = []
def _make_user(name="John", email=None, age=30, role="member"):
if email is None:
email = f"{name.lower()}@example.com"
user = User(name=name, email=email, age=age, role=role)
created_users.append(user)
return user
yield _make_user
# Cleanup
for user in created_users:
user.delete()
def test_admin_permissions(make_user):
admin = make_user(name="Admin", role="admin")
regular = make_user(name="Regular")
assert admin.can_delete_users() == True
assert regular.can_delete_users() == False
Fixtures request-aware
@pytest.fixture
def db_session(request):
"""Fixture qui adapte son comportement selon le test."""
session = SessionLocal()
# Si le test a le marker 'clean_db', vide la DB avant
if request.node.get_closest_marker("clean_db"):
session.execute("DELETE FROM users")
session.commit()
yield session
session.rollback()
session.close()
@pytest.mark.clean_db
def test_count_users(db_session):
assert db_session.query(User).count() == 0
Monkeypatch : modifier l’environnement
def test_with_env_variable(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("DEBUG", "true")
config = load_config()
assert config.database_url == "sqlite:///:memory:"
assert config.debug == True
def test_without_home_dir(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
result = get_config_path()
assert str(tmp_path) in result
Récapitulatif des commandes essentielles
# Base
pytest # Tous les tests
pytest -v # Verbose
pytest -x # Stop au premier échec
pytest -s # Affiche les prints
pytest -k "pattern" # Filtre par nom
pytest -m "marker" # Filtre par marker
# Debug
pytest --lf # Seulement les derniers échoués
pytest --ff # Derniers échoués en premier
pytest --pdb # Debugger sur échec
pytest --tb=long # Traceback détaillé
# Performance
pytest -n auto # Parallèle (xdist)
pytest --durations=10 # Top 10 tests les plus lents
# Coverage
pytest --cov=src # Avec coverage
pytest --cov=src --cov-fail-under=80 # Échec si < 80%
Le prochain cours couvre les tests d’intégration : testcontainers, API testing, et comment tester avec de vraies bases de données. 🔬
À toi de jouer
Trois exercices pour ancrer ce que tu viens de lire.
Exercice 1 — Tests d’un module utilitaire
Crée un module utils.py avec 3 fonctions : parse_config(filepath) qui lit un YAML, validate_email(email) qui vérifie le format, et retry(func, max_attempts) qui réessaie une fonction. Écris les tests pytest avec : au moins un parametrize pour les cas valides/invalides, une fixture pour les fichiers temporaires, et un mock pour simuler des échecs.
Exercice 2 — Coverage à 90%
Prends un petit projet existant (ou crée un mini-CLI avec click). Lance pytest --cov et identifie les lignes non couvertes. Écris les tests manquants pour atteindre 90% de coverage. Configure pyproject.toml pour que le CI échoue sous 85%.
Exercice 3 — Markers et organisation
Organise une suite de tests avec des markers : @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.slow. Configure pytest.ini pour enregistrer les markers. Écris un Makefile avec des targets test-unit, test-integration, test-all. Vérifie que pytest -m unit ne lance que les tests unitaires.
Contenu réservé aux abonnés
Ce chapitre fait partie de la formation complète. Abonne-toi pour débloquer tous les contenus.
Débloquer pour 29 CHF/moisLe chapitre 1 de chaque formation est gratuit.
Série pas encore débloquée
Termine la série prérequise d'abord pour accéder à ce contenu.
Aller à la série prérequiseSérie : Tests & Qualité
2 / 4Sur cette page
Articles liés
Tests d'intégration : testcontainers et API testing
Teste avec de vraies bases de données grâce à testcontainers, valide tes APIs avec des tests HTTP complets, et maîtrise les patterns d'intégration testing.
FastAPI : ta première API en Python
Comprends les APIs, le protocole HTTP, l'architecture REST, puis installe FastAPI et crée ton premier endpoint. Le socle pour tout ce qui suit.
Routes, paramètres et validation
Path parameters, query parameters, validation des données avec Pydantic et gestion des headers HTTP dans FastAPI.