Retour au cours

backend / fastapi

Tests avec TestClient et pytest

Leçon 191 exercice

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.parametrize pour 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 à isolerMécanisme
Base de donnéesapp.dependency_overrides[get_db] + SQLite en mémoire
État entre testsFixture qui recrée le schéma à chaque test
AuthentificationFixture 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

bash
pip install pytest pytest-asyncio httpx
python
# 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()
python
# 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
python
# 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
python
# 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
python
# 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_status

Résumé

  • app.dependency_overrides remplace get_db par 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.AsyncClient avec ASGITransport teste l'app sans lancer de vrai serveur réseau.
  • pytest.mark.parametrize couvre plusieurs cas de validation sans dupliquer le test.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →