Des dizaines de tests backend en une seule PR : notre stratégie de test pour FastAPI
La description de la pull request disait : « Ajout de la suite de tests – 52 tests couvrant l’authentification, la couche de données et l’ensemble des points de terminaison de l’API. » Elle a été intégrée trois mois après la mise en production du backend FastAPI qu’elle couvrait. Ce n’est pas ainsi que les tests sont censés être intégrés, et ce n’est pas quelque chose à célébrer. Mais cette situation mérite un examen honnête, parce que « tests ajoutés tardivement » est une situation sensiblement différente de « tests jamais ajoutés », et la différence entre les deux est précisément celle qui sépare une base de code que vous pouvez maintenir et une autre que vous finirez par réécrire.
Cet article décrit l’architecture de test que nous avons retenue, les méthodes précises qui se sont avérées durables dans un vrai projet FastAPI, et ce que la PR contient réellement au-delà de son nombre de tests.
Le contexte du projet
Le backend est un service FastAPI gérant des opérations orientées client pour un opérateur e-commerce : gestion des commandes, requêtes sur les stocks, règles de tarification, et une intégration avec une API logistique tierce. La base de données est PostgreSQL via SQLAlchemy avec prise en charge des opérations asynchrones. L’authentification est basée sur JWT, avec des jetons validés auprès d’un service d’authentification distinct. Trois API externes sont appelées lors du fonctionnement normal : le fournisseur logistique, un service de conversion de devises et un flux de données produit.
Trois mois de fonctionnement en production signifiaient trois mois de corrections de bugs, d’ajouts de fonctionnalités et de changements incrémentaux – le tout sans tests. La base de code n’était pas impossible à tester. Elle avait été construite pour être testable : dépendances injectées, pas d’état global, sessions de base de données créées par requête via le système d’injection de dépendances de FastAPI. Les tests n’avaient simplement jamais été écrits. Une PR ciblée était le bon moyen d’y parvenir – pas parce que livrer la couverture de tests en masse est une pratique à recommander, mais parce que « une PR avec des tests » est examinée, fusionnée et maintenue. « Tests ajoutés progressivement » tend à rester une tâche en attente indéfiniment.
La hiérarchie des fixtures
Le système de fixtures de pytest bénéficie d’une conception réfléchie. La hiérarchie naturelle pour un projet FastAPI avec une vraie base de données suit trois portées : session, module et fonction ; cette suite s’appuie principalement sur les portées session et fonction.
Les fixtures de portée session s’exécutent une fois par exécution de tests. Création de base de données, mise en place du schéma (l’exemple ci-dessous utilise Base.metadata.create_all ; les migrations Alembic interviennent à cette même étape), et peuplement de tables avec des données de référence qui ne changent pas entre les tests – tout cela relève de cette portée. Le moteur de base de données de portée session est partagé entre tous les tests, ce qui signifie que le coût de création du schéma est payé une fois, pas une fois par fichier de tests.
Les fixtures de portée fonction fournissent à chaque test son propre état isolé. La fixture d’annulation de transaction est essentielle : elle ouvre une transaction au début de chaque test et l’annule à la fin. Toutes les écritures que le test effectue – créer des utilisateurs, insérer des commandes, mettre à jour des prix – sont annulées automatiquement. Les tests sont isolés sans tronquer les tables entre les exécutions.
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Chaque test obtient une transaction qui rollback à la fin."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
L’appel begin_nested() crée un point de sauvegarde dans la transaction externe. L’objectif est que le code de test puisse valider librement ses modifications dans sa session, tandis qu’une annulation de la transaction externe lors du nettoyage annule tout. La fixture présentée n’y parvient pas entièrement : engine.begin() valide la transaction lorsque son bloc se termine normalement, et session.rollback() n’annule que les modifications propres à la session. Pour une isolation complète, ouvrez la connexion avec engine.connect(), démarrez la transaction externe avec conn.begin(), liez la session avec join_transaction_mode="create_savepoint", puis annulez explicitement la transaction externe après le test. Avec cette configuration, les appels à session.commit() dans le code applicatif ne font que libérer les points de sauvegarde, et l’annulation de la transaction externe annule toujours ces modifications.
Simuler les API externes
Trois API externes signifient trois sources potentielles d’instabilité pour les tests qui accèdent au réseau. La solution standard – simuler le client HTTP – est simple avec respx, qui intercepte les requêtes httpx au niveau de la couche transport.
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
Le gestionnaire de contexte respx.mock achemine toutes les requêtes httpx sortantes vers la couche de simulation. Toute requête non explicitement enregistrée lève une erreur – ce qui est le bon comportement par défaut. Les tests qui atteignent accidentellement des API de production sont une catégorie de bugs difficile à détecter et coûteuse quand ils causent des effets secondaires.
Pour le service de conversion de devises, appelé à chaque commande avec une devise autre que l’EUR, une fixture partagée fournit un routeur respx préconfiguré avec des taux de change standard. Les tests couvrant la logique de conversion remplacent les taux de certaines paires de devises ; les tests couvrant un comportement sans rapport qui déclenche quand même la dépendance au service de conversion utilisent les valeurs par défaut de la fixture sans y penser. Cette séparation garde l’intention du test claire : les tests sur l’expédition concernent l’expédition, et non le taux de change configuré.
Tester les flux d’authentification
Le système d’injection de dépendances de FastAPI est l’un de ses atouts les plus solides pour les tests. L’authentification dans ce projet est une dépendance : chaque route protégée déclare current_user: User = Depends(get_current_user), où get_current_user valide un JWT et retourne un objet utilisateur. Dans les tests, cette dépendance est remplacée au niveau de l’application :
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Des fixtures séparées pour des rôles distincts rendent explicites les tests des limites des droits d’accès. Un test qui vérifie une réponse 403 quand un opérateur tente une action d’administration se lit aussi clairement que la règle métier qu’il applique. Quand les fixtures sont nommées operator_client et admin_client, l’intention du test est visible sans lire son corps.
Un petit ensemble de tests exerce directement le chemin de code de validation JWT – jetons expirés, jetons malformés, jetons dont les déclarations d’audience sont incorrectes. Ces tests n’utilisent pas la fixture de remplacement ; ils passent des jetons bruts via le client de test et font des assertions sur les réponses 401 ou 403. La séparation est délibérée : la plupart des tests s’intéressent au comportement après l’authentification, ils utilisent donc le remplacement de dépendance. Les tests d’authentification s’intéressent à la logique d’authentification elle-même. Mélanger les deux rend moins clair ce que chaque test couvre réellement.
Tester volontairement les cas d’erreur
Les tests des cas nominaux sont simples à écrire et sont souvent les seuls tests écrits quand le temps manque. Les bugs qui atteignent la production se trouvent rarement dans les cas nominaux. Pour cette base de code, le comportement non testé le plus critique était dans la gestion des erreurs : que se passe-t-il quand l’API logistique retourne 503 ? Que retourne l’API quand des champs de requête requis sont manquants ? Que se passe-t-il quand une contrainte de base de données est violée par une modification concurrente ?
Pour chaque simulation d’API externe, nous incluons au moins un cas d’erreur à côté du cas de succès. La fixture de l’API logistique a une variante qui retourne 503, confirmant que le point de terminaison retourne une réponse d’erreur structurée avec un code de statut HTTP approprié plutôt que de laisser l’exception se propager comme un 500. Les tests de contraintes de base de données tentent des insertions en conflit et confirment que l’API renvoie un code 409 plutôt qu’une erreur d’intégrité non gérée.
Cette couverture n’émerge pas naturellement de l’écriture de tests après coup. Elle nécessite une énumération délibérée des modes d’échec lors de la conception des tests, ce qui est un argument honnête pour écrire les tests en parallèle avec le code plutôt qu’après – les modes d’échec sont plus faciles à énumérer quand l’implémentation est encore récente et que les cas limites sont encore en mémoire de travail.
Le retour sur investissement des tests tardifs
Tester avec trois mois de retard vaut mieux que ne jamais tester, pour des raisons qui ne sont pas philosophiques. La PR a détecté trois bugs existants lors de l’écriture des tests : une vérification de valeur nulle manquante sur un champ d’adresse optionnel qui causait un 500 sur les commandes sans deuxième ligne d’adresse de facturation, une erreur de décalage d’une unité dans la logique de pagination qui ne se manifestait qu’avec des ensembles de résultats vides, et un code de statut HTTP incorrect (200 au lieu de 201) sur un point de terminaison de création de ressource. Ces bugs étaient en production depuis des mois sans déclencher de plainte utilisateur – ce qui est précisément pourquoi ils n’avaient pas été détectés. Les tests les ont trouvés en un après-midi.
Le retour le plus durable est la documentation comportementale. Une suite de tests qui couvre les limites d’accès liées à l’authentification, les interactions avec les API externes et les conditions d’erreur décrit le comportement de l’API et reste exacte tant que les tests passent. Le test pour « l’opérateur ne peut pas accéder au point de terminaison d’administration » ne se contente pas d’attraper les régressions – il communique l’intention à quiconque lit la base de code ensuite. Pour un service qui sera maintenu par quelqu’un d’autre que son auteur d’origine – ce qui finit par être le cas de tout service – cette spécification vaut considérablement plus que les bugs qu’elle attrape au moment de l’écriture.
L’argument contre l’écriture tardive de tests est que le coût est concentré au départ : une grande PR, un effort de revue concentré, du temps qui aurait pu aller aux fonctionnalités. C’est exact. Mais l’alternative – une base de code qui reste non testée parce que le bon moment d’ajouter des tests n’arrive jamais tout à fait – entraîne un coût cumulatif qui est rarement mesuré parce qu’il se répartit sur toutes les modifications ultérieures. Une suite de tests tardive est coûteuse une fois. L’absence de suite de tests coûte en permanence.