backend / fastapi
Tests avec TestClient et pytest
Explication
Ce que vous allez apprendre
- Simuler des requêtes HTTP contre une application FastAPI sans démarrer de serveur réseau
- Isoler chaque test avec une base de données de test recréée à chaque fois
- Remplacer une dépendance réelle par une version de test avec
app.dependency_overrides - Tester à la fois le chemin heureux et les cas d'erreur attendus (404, 422)
- Paramétrer un test avec
pytest.mark.parametrizepour couvrir plusieurs cas sans duplication
Dans quel contexte ?
Après avoir corrigé un bug sur l'endpoint POST /products de app/routers/products.py, un développeur casse silencieusement la validation du prix négatif sans s'en rendre compte, car personne n'a retesté ce cas précis manuellement. Une suite de tests avec TestClient, incluant un cas test_product_price_validation paramétré sur plusieurs valeurs de prix (négatif, nul, valide), aurait détecté cette régression en quelques secondes, avant même que le code ne parte en revue.
Au-delà de "vérifier à la main"
Vérifier manuellement chaque endpoint après chaque changement de code devient vite intenable. Un humain oublie des cas limites, se lasse de répéter les mêmes vérifications.
Des tests automatisés exécutent ces vérifications en quelques secondes, de façon systématique et reproductible. Un filet de sécurité qui donne confiance pour faire évoluer l'application sans tout casser silencieusement.
Le rôle central de ces tests revient à un outil précis : TestClient. Basé sur httpx, il simule des requêtes HTTP contre l'application FastAPI SANS démarrer de vrai serveur réseau.
Les appels sont directs, en mémoire, ce qui rend les tests rapides. Aucun port ni processus séparé n'est nécessaire pour les exécuter.
Une fois cet outil en main, un principe fondamental doit guider chaque test : l'isolation. Un test ne doit JAMAIS dépendre de l'état laissé par un test précédent, ni polluer la vraie base de données de développement.
C'est le rôle de app.dependency_overrides. Il remplace, uniquement pour la durée des tests, la dépendance get_db réelle par une base de test isolée, souvent SQLite en mémoire.
Cette base est recréée de zéro à chaque test grâce à une fixture pytest. Chaque test part ainsi d'un état parfaitement propre.
Une fois l'isolation garantie, que faut-il tester concrètement ? Une bonne suite de tests ne vérifie pas seulement le "chemin heureux", une création qui réussit.
Elle vérifie aussi les cas d'erreur attendus : une ressource introuvable renvoie bien 404, un payload invalide renvoie bien 422. pytest.mark.parametrize évite de dupliquer le même test pour plusieurs valeurs d'entrée.
Bonne pratique
Ne teste pas uniquement le "chemin heureux" (création réussie). Ajoute systématiquement un test pour chaque cas d'erreur attendu : ressource introuvable (404), payload invalide (422), accès non autorisé (401/403). Ce sont ces cas-là qui régressent le plus silencieusement.
| Élément à isoler | Mécanisme |
|---|---|
| Base de données | app.dependency_overrides[get_db] + SQLite en mémoire |
| État entre tests | Fixture qui recrée le schéma à chaque test |
| Authentification | Fixture dédiée qui génère un token valide |
Et la suite ? Cette leçon rassemble concrètement des notions vues plus tôt, la dépendance get_db et l'authentification par token : les tester ensemble valide que toute la chaîne fonctionne, pas seulement chaque brique isolément.
Commandes & code
Tests avec TestClient et pytest
pip install pytest pytest-asyncio httpx# tests/conftest.py — fixtures partagées : app de test + DB isolée
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.core.database import Base, get_db
TEST_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture()
def db_session():
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine) # base propre à chaque test
@pytest.fixture()
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db # remplace la vraie DB par celle de test
yield TestClient(app)
app.dependency_overrides.clear()# tests/test_products.py — tests d'intégration sur les endpoints
def test_create_product(client):
response = client.post("/products", json={"name": "Clavier", "price": 49.99})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Clavier"
assert "id" in data
def test_get_nonexistent_product_returns_404(client):
response = client.get("/products/999")
assert response.status_code == 404
assert response.json()["detail"] == "Produit introuvable"
def test_create_product_missing_field_returns_422(client):
response = client.post("/products", json={"name": "Clavier"}) # "price" manquant
assert response.status_code == 422# Fixture d'authentification — obtenir un token valide pour les tests de routes protégées
@pytest.fixture()
def authenticated_headers(client, db_session):
user = User(email="test@example.com", hashed_password=hash_password("secret123"))
db_session.add(user)
db_session.commit()
response = client.post("/auth/login", data={"username": "test@example.com", "password": "secret123"})
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_protected_route_requires_auth(client):
response = client.get("/me")
assert response.status_code == 401
def test_protected_route_with_valid_token(client, authenticated_headers):
response = client.get("/me", headers=authenticated_headers)
assert response.status_code == 200# Tests async avec httpx.AsyncClient — utile quand l'app utilise des routes async + DB async
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.asyncio
async def test_async_endpoint():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
response = await ac.get("/products")
assert response.status_code == 200# Paramétrage de tests — éviter la duplication pour tester plusieurs cas similaires
import pytest
@pytest.mark.parametrize(
"price,expected_status",
[
(-10, 422), # prix négatif refusé
(0, 422), # prix nul refusé (PositiveFloat)
(19.99, 201), # prix valide
],
)
def test_product_price_validation(client, price, expected_status):
response = client.post("/products", json={"name": "Test", "price": price})
assert response.status_code == expected_statusRésumé
app.dependency_overridesremplaceget_dbpar une session de test (SQLite en mémoire ou DB dédiée).- Une fixture recrée un schéma propre à chaque test, garantissant l'isolation entre tests.
httpx.AsyncClientavecASGITransportteste l'app sans lancer de vrai serveur réseau.pytest.mark.parametrizecouvre plusieurs cas de validation sans dupliquer le test.
Exercices pratiques
Mission : le test qui passe seul mais échoue dans la suite complète
Objectif : Diagnostiquer une fuite d'isolation entre tests et compléter la couverture de validation d'un endpoint avec des tests paramétrés.
Contexte
tests/test_products.py compte désormais une vingtaine de tests. test_create_product réussit systématiquement lancé seul, mais échoue de façon intermittente quand toute la suite tourne dans un ordre différent. Par ailleurs, personne n'a encore écrit de test couvrant la validation du prix négatif sur POST /products, alors que c'est justement ce genre de régression silencieuse que cette leçon vise à éviter.