tva
← Insights

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'auth, la couche données et la surface API.” Elle a atterri trois mois après que le backend FastAPI qu'elle couvrait était en production. Ce n'est pas ainsi que les tests sont censés fonctionner, et ce n'est pas quelque chose à célébrer. Mais ça vaut la peine d'examiner honnêtement, parce que “tests ajoutés tardivement” est une situation matériellement différente de “tests jamais ajoutés”, et la distance entre les deux est exactement la distance entre une base de code que vous pouvez maintenir et une que vous réécrivez éventuellement.

Cet article décrit l'architecture de test sur laquelle nous nous sommes fixés, les patterns spécifiques qui se sont avérés 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 d'inventaire, règles de tarification, et une intégration avec une API logistique tierce. La base de données est PostgreSQL via SQLAlchemy avec support async. L'authentification est basée sur JWT, avec des tokens validés contre un service d'auth séparé. Trois APIs externes sont appelées lors du fonctionnement normal : le fournisseur logistique, un service de conversion de devises et un flux de données produits.

Trois mois de fonctionnement en production signifiaient trois mois de corrections de bugs, d'ajouts de fonctionnalités et de changements incrémentaux – tout sans tests. La base de code n'était pas impossible à tester. Elle avait été construite avec la testabilité en tête : 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 vecteur – pas parce que livrer la couverture de tests en masse est un pattern à recommander, mais parce que “une PR avec des tests” est revue, mergée et maintenue. “Tests ajoutés progressivement” tend à rester un élément de backlog indéfiniment.

La hiérarchie de fixtures

Le système de fixtures de pytest récompense une conception délibérée. La hiérarchie naturelle pour un projet FastAPI avec une vraie base de données suit trois périmètres : session, module et fonction.

Les fixtures à périmètre session s'exécutent une fois par exécution de tests. Création de base de données, migration de schéma via Alembic, et peuplement de tables avec des données de référence qui ne changent pas entre les tests – tout cela va ici. Le moteur de base de données à périmètre session est partagé entre tous les tests, ce qui signifie que le coût d'application des migrations est payé une fois, pas une fois par fichier de tests.

Les fixtures à périmètre fonction fournissent à chaque test son propre état isolé. La fixture de rollback de transaction est la critique : elle ouvre une transaction au début de chaque test et la rollback à 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 savepoint dans la transaction externe. Le code de test peut committer librement dans sa session, mais le rollback de la transaction externe au teardown annule tout. Ce pattern fonctionne avec la couche async de SQLAlchemy, bien qu'il nécessite une attention particulière lors du test de code qui appelle explicitement session.commit() – ces commits se complètent dans le savepoint et sont quand même rollback par la transaction externe.

Mocker les APIs externes

Trois APIs externes signifient trois sources potentielles de flakiness dans les tests qui touchent le réseau. La solution standard – mocker 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 route toutes les requêtes httpx sortantes via la couche mock. 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 APIs 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 dénomination non EUR, une fixture partagée fournit un routeur respx préconfiguré avec des taux de change standard. Les tests couvrant la logique de devise overrident des paires de taux spécifiques ; les tests couvrant un comportement sans rapport qui déclenche quand même la dépendance de devise 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, pas quel taux de change est 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 overridé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 séparés rendent les tests de frontières de permissions explicites. Un test qui affirme une réponse 403 quand un opérateur tente une action admin 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 exercent directement le chemin de code de validation JWT – tokens expirés, tokens malformés, tokens avec des claims d'audience incorrects. Ces tests n'utilisent pas la fixture d'override ; ils passent des tokens 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 se soucient du comportement après l'authentification, ils utilisent donc l'override. Les tests d'authentification se soucient de la logique d'authentification elle-même. Mélanger les deux obscurcit ce que chaque test couvre réellement.

Tester les chemins d'erreur délibérément

Les tests du chemin heureux sont simples à écrire et sont souvent les seuls tests écrits sous pression temporelle. Les bugs qui atteignent la production se trouvent rarement dans le chemin heureux. 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 mock 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 l'endpoint retourne une réponse d'erreur structurée avec un code de statut HTTP approprié plutôt 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 surface un 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 fraîche et que les cas limites sont encore en mémoire de travail.

Le ROI des tests tardifs

Tester trois mois tard est mieux que ne jamais tester, pour des raisons qui ne sont pas philosophiques. La PR a détecté trois bugs existants lors de la rédaction : 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, un décalage d'un 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 endpoint 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 une après-midi.

Le retour le plus durable est la documentation comportementale. Une suite de tests qui couvre les frontières d'authentification, les interactions avec les APIs externes et les conditions d'erreur est une spécification de ce que l'API fait qui reste précise tant que les tests passent. Le test pour “l'opérateur ne peut pas accéder à l'endpoint admin” 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 est éventuellement vrai 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 front-loadé : 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 – porte un coût cumulatif qui est rarement mesuré parce qu'il est distribué à travers chaque changement ultérieur. Une suite de tests tardive est coûteuse une fois. Pas de suite de tests est coûteux continuellement.

Ressources connexes

Articles connexes