Aller au contenu principal

Tests avec FastAPI

remarque

Ce chapitre vient après Middleware avec FastAPI. Il montre comment tester ton application FastAPI de manière sérieuse, reproductible et de plus en plus proche d’un vrai usage.

Introduction

Jusqu’ici, on a construit une API qui commence à devenir réaliste :

  • base CRUD,
  • authentification,
  • relations SQL,
  • gestion d’erreurs,
  • middleware.

Mais si tu modifies cette API demain, comment sauras-tu que tu n’as rien cassé ?

C’est exactement le rôle des tests.

Ce que ce chapitre va accomplir

À la fin de ce chapitre, tu auras :

  • les dépendances de test adaptées au projet,
  • un dossier tests/,
  • des fixtures pytest,
  • une base de données de test isolée,
  • des overrides de dépendances FastAPI,
  • des tests auth,
  • des tests sur les tâches,
  • une compréhension claire de la différence entre :
    • petit test Python simple,
    • test API,
    • environnement de test,
    • base de test.

Pourquoi les tests arrivent maintenant

Le moment est bon pour introduire les tests parce que :

  • l’architecture du projet est maintenant suffisamment stable,
  • il y a déjà plusieurs comportements importants à verrouiller,
  • l’app commence à avoir des règles métier qu’on ne veut plus vérifier seulement à la main.

Autrement dit :

  • au début, curl suffisait pour apprendre,
  • maintenant, il faut passer à une vérification automatisée.

Sources d’inspiration réelles

Ce chapitre s’appuie principalement sur :

  • 29-pytest/142-report.py
  • 29-pytest/142-report_test.py
  • 30-API Testing/148-conftest.py
  • 30-API Testing/148-example.py
  • 30-API Testing/149-conftest.py
  • 30-API Testing/149-test_shipment.py

Ce que ces sources montrent :

  • le principe d’un petit test pytest,
  • le principe d’une fixture,
  • l’usage de httpx.AsyncClient,
  • l’override des dépendances FastAPI,
  • l’usage d’une base SQLite mémoire pour les tests,
  • des tests API authentifiés.

Choix pédagogique de ce chapitre

Les sources couvrent deux niveaux :

Niveau 1 — pytest simple

Le dossier 29-pytest montre le principe de base :

  • une fixture,
  • une fonction,
  • des assertions.

Niveau 2 — tests API complets

Le dossier 30-API Testing montre un niveau plus concret pour FastAPI :

  • client HTTP de test,
  • base mémoire,
  • override de get_session,
  • création de données de test,
  • tests auth et API.

Pour notre wiki, on va :

  • mentionner rapidement le niveau 1,
  • puis surtout transformer le niveau 2 en guide adapté à notre propre projet.

Important : lien avec le TDD

Le meilleur workflow reste :

  • écrire le test d’abord,
  • le voir échouer,
  • écrire le code minimal,
  • le voir passer.

Ici, comme notre projet existe déjà, on ajoute aussi des tests après coup pour sécuriser le socle construit.

Mais pour les futures évolutions, retiens ceci :

plus ton projet grandit, plus écrire les tests tôt devient rentable.

Architecture visée après ce chapitre

On ajoute simplement un dossier tests/ :

fastapi-base-api/
├── main.py
├── config.py
├── requirements.txt
├── .gitignore
├── .env
├── .env.example
├── core/
│ ├── __init__.py
│ ├── security.py
│ ├── exceptions.py
│ └── middleware.py
├── api/
│ ├── __init__.py
│ ├── router.py
│ ├── dependencies.py
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── task.py
│ │ └── user.py
│ └── schemas/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
├── database/
│ ├── __init__.py
│ ├── models.py
│ └── session.py
├── services/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
└── tests/
├── __init__.py
├── example.py
├── conftest.py
├── test_auth.py
└── test_task.py

Repères utiles avant le code

Avant de commencer, retiens ces idées :

  • un test ne doit pas dépendre de ta base locale réelle,
  • un test doit pouvoir être relancé facilement,
  • une fixture sert à préparer un contexte réutilisable,
  • httpx.AsyncClient permet de parler à ton app FastAPI sans lancer un vrai serveur externe,
  • dependency_overrides est la clé pour brancher une base de test.

Étape 1 — ajouter les dépendances de test

Ajoute dans requirements.txt :

fastapi
uvicorn[standard]
sqlmodel
sqlalchemy
asyncpg
pydantic-settings
scalar-fastapi
passlib[bcrypt]
PyJWT
python-multipart
email-validator
pytest
pytest-asyncio
httpx
aiosqlite

Pourquoi ces dépendances ?

  • pytest : moteur de test.
  • pytest-asyncio : pour tester du code async proprement.
  • httpx : client HTTP utilisé pour tester les endpoints.
  • aiosqlite : base SQLite async en mémoire pour isoler les tests.

Installe ensuite :

pip install -r requirements.txt

Étape 2 — créer le dossier tests/

mkdir -p tests
touch tests/__init__.py tests/example.py tests/conftest.py tests/test_auth.py tests/test_task.py

Étape 3 — comprendre le modèle simple vu dans 29-pytest

La source 29-pytest montre une idée très simple :

  • une fonction produit une donnée,
  • une fixture prépare cette donnée,
  • un test vérifie le résultat.

Ce n’est pas encore un test API complet, mais c’est utile pour comprendre :

  • qu’un test est juste une fonction,
  • qu’une fixture est juste un contexte réutilisable.

Étape 4 — préparer les données de test dans tests/example.py

Ici, on va préparer des utilisateurs de test réutilisables.

from sqlalchemy.ext.asyncio import AsyncSession

from core.security import hash_password
from database.models import User


TEST_USER_ENES = {
"name": "Enes",
"email": "enes@test.local",
"password": "StrongPass123",
}

TEST_USER_ALICE = {
"name": "Alice",
"email": "alice@test.local",
"password": "StrongPass456",
}


async def create_test_data(session: AsyncSession):
session.add(
User(
name=TEST_USER_ENES["name"],
email=TEST_USER_ENES["email"],
password_hash=hash_password(TEST_USER_ENES["password"]),
)
)

session.add(
User(
name=TEST_USER_ALICE["name"],
email=TEST_USER_ALICE["email"],
password_hash=hash_password(TEST_USER_ALICE["password"]),
)
)

await session.commit()

Pourquoi ce fichier existe ?

Parce qu’on veut centraliser les données de test au lieu de les recopier partout.

Cela rend les tests :

  • plus lisibles,
  • plus courts,
  • plus faciles à faire évoluer.

Étape 5 — créer tests/conftest.py

C’est le cœur de l’environnement de test.

import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel

from database.session import get_session
from main import app
from tests import example


engine = create_async_engine("sqlite+aiosqlite:///:memory:")
test_session = sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)


async def get_session_override():
async with test_session() as session:
yield session


@pytest_asyncio.fixture(scope="session")
async def client():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client


@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_and_teardown():
app.dependency_overrides[get_session] = get_session_override

async with engine.begin() as connection:
from database.models import Task, User # noqa: F401
await connection.run_sync(SQLModel.metadata.create_all)

async with test_session() as session:
await example.create_test_data(session)

yield

async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)

app.dependency_overrides.clear()


@pytest_asyncio.fixture(scope="session")
async def token_enes(client: AsyncClient):
response = await client.post(
"/user/token",
data={
"grant_type": "password",
"username": example.TEST_USER_ENES["email"],
"password": example.TEST_USER_ENES["password"],
},
)
assert response.status_code == 200
return response.json()["access_token"]


@pytest_asyncio.fixture(scope="session")
async def token_alice(client: AsyncClient):
response = await client.post(
"/user/token",
data={
"grant_type": "password",
"username": example.TEST_USER_ALICE["email"],
"password": example.TEST_USER_ALICE["password"],
},
)
assert response.status_code == 200
return response.json()["access_token"]

Ce que ce fichier fait vraiment

1. il crée une base de test isolée

Grâce à :

create_async_engine("sqlite+aiosqlite:///:memory:")

2. il remplace la vraie session DB

Grâce à :

app.dependency_overrides[get_session] = get_session_override

3. il crée les tables et injecte des données de test

Grâce à create_all() puis example.create_test_data(session).

4. il fournit des tokens de test prêts à l’emploi

Ce qui simplifie beaucoup les tests auth.

Étape 6 — écrire tests/test_auth.py

On commence par verrouiller l’authentification.

from httpx import AsyncClient


async def test_login_returns_access_token(client: AsyncClient):
response = await client.post(
"/user/token",
data={
"grant_type": "password",
"username": "enes@test.local",
"password": "StrongPass123",
},
)

assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"


async def test_login_with_wrong_password_fails(client: AsyncClient):
response = await client.post(
"/user/token",
data={
"grant_type": "password",
"username": "enes@test.local",
"password": "WrongPassword",
},
)

assert response.status_code == 401
assert response.json()["detail"] == "Email or password is incorrect"


async def test_me_requires_authentication(client: AsyncClient):
response = await client.get("/user/me")
assert response.status_code == 401


async def test_me_returns_current_user(client: AsyncClient, token_enes: str):
response = await client.get(
"/user/me",
headers={"Authorization": f"Bearer {token_enes}"},
)

assert response.status_code == 200
data = response.json()
assert data["email"] == "enes@test.local"
assert data["name"] == "Enes"

Pourquoi commencer par l’auth ?

Parce que si l’auth ne marche pas, beaucoup de tests métiers suivants vont être fragiles.

On verrouille donc d’abord :

  • le login,
  • l’échec du login,
  • l’accès au profil courant.

Étape 7 — écrire tests/test_task.py

Maintenant, on teste les routes métier autour des tâches.

from httpx import AsyncClient


async def test_create_task_requires_authentication(client: AsyncClient):
response = await client.post(
"/task/",
json={
"title": "Task sans auth",
"description": "Ne devrait pas passer",
},
)

assert response.status_code == 401


async def test_user_can_create_and_list_own_tasks(
client: AsyncClient,
token_enes: str,
):
create_response = await client.post(
"/task/",
headers={"Authorization": f"Bearer {token_enes}"},
json={
"title": "Task d'Enes",
"description": "Créée depuis un test",
},
)

assert create_response.status_code == 201
created = create_response.json()
assert created["owner_id"] == 1
assert created["title"] == "Task d'Enes"

list_response = await client.get(
"/task/mine",
headers={"Authorization": f"Bearer {token_enes}"},
)

assert list_response.status_code == 200
tasks = list_response.json()
assert len(tasks) >= 1
assert any(task["id"] == created["id"] for task in tasks)


async def test_user_cannot_read_another_user_task(
client: AsyncClient,
token_enes: str,
token_alice: str,
):
create_response = await client.post(
"/task/",
headers={"Authorization": f"Bearer {token_enes}"},
json={
"title": "Task privée",
"description": "Appartient à Enes",
},
)

assert create_response.status_code == 201
task_id = create_response.json()["id"]

read_response = await client.get(
f"/task/?id={task_id}",
headers={"Authorization": f"Bearer {token_alice}"},
)

assert read_response.status_code == 404
assert read_response.json()["detail"] == "Task not found"


async def test_empty_patch_returns_400(
client: AsyncClient,
token_enes: str,
):
create_response = await client.post(
"/task/",
headers={"Authorization": f"Bearer {token_enes}"},
json={
"title": "Task à patcher",
"description": "Test de patch vide",
},
)

task_id = create_response.json()["id"]

patch_response = await client.patch(
f"/task/?id={task_id}",
headers={"Authorization": f"Bearer {token_enes}"},
json={},
)

assert patch_response.status_code == 400
assert patch_response.json()["detail"] == "No data provided to update"

Ce que ces tests vérifient vraiment

  • qu’une route protégée refuse un accès anonyme,
  • qu’un utilisateur authentifié peut créer une tâche,
  • qu’il peut lister ses tâches,
  • qu’un autre utilisateur ne voit pas cette tâche,
  • que le comportement d’erreur métier est bien stable.

Autrement dit :

  • auth,
  • ownership,
  • error handling,
  • tout cela est maintenant réellement testé.

Étape 8 — lancer les tests

Lancer toute la suite

pytest -q

Lancer seulement l’auth

pytest tests/test_auth.py -v

Lancer seulement les tâches

pytest tests/test_task.py -v

À quoi t’attendre dans les logs de test

Tu verras probablement :

  • création des tables de test,
  • exécution des fixtures,
  • appels HTTP via httpx.

Le plus important n’est pas le volume de sortie, mais le fait que les tests soient :

  • répétables,
  • isolés,
  • indépendants de ta vraie base locale.

Pourquoi SQLite mémoire ici ?

Parce qu’on veut :

  • des tests rapides,
  • isolés,
  • faciles à lancer.

La source 30-API Testing montre déjà cette logique.

Pour un premier niveau pédagogique, c’est un très bon choix.

Limite importante à connaître

Ta prod ou ton dev principal utilisent PostgreSQL, mais les tests ici utilisent SQLite mémoire.

Cela veut dire :

  • très bien pour des tests de comportement général,
  • mais pas parfait pour détecter toutes les différences SQL fines entre SQLite et PostgreSQL.

Donc plus tard, tu pourras avoir deux niveaux :

  1. tests rapides avec SQLite mémoire,
  2. tests plus proches de la réalité avec PostgreSQL de test.

Bonus — quand écrire les tests ?

Le meilleur réflexe, surtout maintenant que l’architecture existe, c’est :

  • écrire un test pour chaque nouveau comportement important,
  • écrire un test avant un refactor,
  • écrire un test avant une correction de bug.

Exemple de bon réflexe :

  • bug sur une route → écrire d’abord un test qui reproduit le bug.

Pièges et erreurs fréquentes

  • utiliser la vraie base locale au lieu d’une base de test
  • oublier app.dependency_overrides
  • oublier d’importer les modèles avant create_all()
  • mélanger trop de responsabilités dans une seule fixture
  • tester seulement le cas heureux
  • oublier les cas d’échec auth ou ownership
  • croire que curl manuel suffit pour stabiliser une app qui grandit

Bonnes pratiques à retenir

  • garder une base de test isolée
  • écrire des fixtures lisibles
  • nommer les tests selon le comportement attendu
  • tester à la fois :
    • succès,
    • échecs,
    • permissions,
    • erreurs métier
  • séparer tests auth et tests métier si possible

Variantes et évolutions possibles

Après ce chapitre, tu peux aller vers :

  • tests PostgreSQL dédiés,
  • tests de middleware,
  • tests des handlers d’erreur,
  • tests de reset password / email / Celery,
  • couverture de code,
  • pipeline CI.

Checklist de validation

Avant de passer à la suite, vérifie que tu peux :

  • lancer une suite de tests isolée sans utiliser ta base locale réelle
  • expliquer à quoi servent app.dependency_overrides et AsyncClient
  • montrer comment tester auth, ownership et erreurs métier
  • distinguer un test manuel ponctuel d’une vérification automatisée rejouable
  • nommer les cas critiques que tu veux garder sous surveillance

Résumé final

Dans ce chapitre, tu as appris à transformer ton projet FastAPI en application testable proprement.

Tu as vu comment :

  • préparer un environnement de test,
  • utiliser pytest et pytest-asyncio,
  • appeler ton app avec httpx.AsyncClient,
  • override la session DB,
  • tester auth, ownership et erreurs métier.

Si tu retiens une seule chose, retiens celle-ci :

une API devient vraiment fiable quand ses comportements importants sont rejouables automatiquement, sans dépendre d’un test manuel au terminal.

Pour aller plus loin