data / sqlalchemy
Tester avec une base en mémoire
Explication
Ce que vous allez apprendre
- Créer une base SQLite en mémoire pour des tests rapides et isolés
- Éviter que des tests se marchent dessus grâce à une transaction annulée après chacun
- Connaître les limites de SQLite par rapport à PostgreSQL (types, contraintes avancées)
- Choisir entre SQLite en mémoire et un PostgreSQL éphémère (testcontainers) selon la criticité du code
Dans quel contexte ?
Une suite de tests pour une API de gestion de commandes doit vérifier des dizaines de scénarios (commande valide, stock insuffisant, remise appliquée) sans dépendre d'un serveur PostgreSQL externe à démarrer avant chaque exécution de la CI. Une base SQLite en mémoire, créée et détruite à la volée pour chaque test, permet à cette suite de s'exécuter en quelques secondes au lieu de plusieurs minutes.
D'abord, un problème que les tests purs n'ont pas
Tester une fonction pure, qui prend une entrée et retourne une sortie, est simple. Tester du code qui lit et écrit dans une base de données pose un problème supplémentaire : il faut un état de départ connu, et il ne faut surtout pas que les tests se polluent les uns les autres.
Étape 1 : une base rapide et jetable
Une base SQLite qui n'existe qu'en mémoire (:memory:) le temps du test résout une partie du problème : elle est créée vide, très rapide, et disparaît automatiquement à la fin. C'est un compromis pratique — on sacrifie une fidélité parfaite à la base de production contre une vitesse d'exécution bien supérieure.
| Option | Vitesse | Fidélité à la production |
|---|---|---|
SQLite :memory: | très rapide | limitée (types/contraintes avancées absents) |
| PostgreSQL éphémère (testcontainers) | plus lente | élevée, comportement identique à la prod |
Il reste un problème : les tests peuvent se marcher dessus
Même avec une base fraîche par session de test, plusieurs tests dans la même suite peuvent interférer entre eux s'ils partagent la même base et laissent des données résiduelles derrière eux.
Étape 2 : annuler systématiquement après chaque test
Le pattern de la transaction englobante annulée systématiquement, un rollback() après chaque test même après un commit() fait dans le test, garantit qu'aucun test ne laisse de trace pour le suivant, quel que soit ce qu'il a fait.
Une limite importante à connaître
SQLite diverge de PostgreSQL sur des types et comportements avancés, comme JSONB ou certaines contraintes spécifiques. Un test qui passe sur SQLite peut donc, en théorie, échouer sur la vraie base de production.
Piège fréquent
Un test qui vérifie un comportement spécifique à PostgreSQL (une colonne JSONB, une contrainte d'exclusion) peut passer sur SQLite en mémoire tout en échouant silencieusement en production, car SQLite ne reproduit pas fidèlement ces comportements. Pour du code critique dépendant de fonctionnalités spécifiques à PostgreSQL, préférez un PostgreSQL éphémère (testcontainers).
Pour aller plus loin
Pour des tests qui doivent refléter fidèlement le comportement de production, un PostgreSQL éphémère, via testcontainers par exemple, est préférable, au prix d'une exécution plus lente — un arbitrage à faire consciemment selon la criticité du code testé.
Vers la suite
Une fois les tests fiables et rapides, la prochaine leçon montre comment brancher automatiquement de la logique métier sur des événements du cycle de vie des objets, sans avoir à l'appeler manuellement partout.
Commandes & code
Tester avec une base en mémoire
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import StaticPool
# SQLite en mémoire : rapide, isolé, aucune installation externe requise pour les tests
@pytest.fixture()
def engine_test():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool, # une seule connexion partagée -- nécessaire pour :memory:
)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
@pytest.fixture()
def session(engine_test) -> Session:
SessionTest = sessionmaker(bind=engine_test)
with SessionTest() as s:
yield s
s.rollback() # annule tout changement non committé entre deux tests
def test_creation_utilisateur(session: Session):
u = Utilisateur(email="test@exemple.com", nom="Test")
session.add(u)
session.commit()
resultat = session.scalar(select(Utilisateur).where(Utilisateur.email == "test@exemple.com"))
assert resultat is not None
assert resultat.nom == "Test"
def test_contrainte_unique_email(session: Session):
session.add(Utilisateur(email="dup@x.com", nom="A"))
session.commit()
session.add(Utilisateur(email="dup@x.com", nom="B"))
with pytest.raises(Exception): # IntegrityError attendu
session.commit()
session.rollback()
def test_relation_one_to_many(session: Session):
auteur = Auteur(nom="Ada")
auteur.articles.append(Article(titre="Premier article"))
session.add(auteur)
session.commit()
resultat = session.scalar(select(Auteur).where(Auteur.nom == "Ada"))
assert len(resultat.articles) == 1
assert resultat.articles[0].titre == "Premier article"
# Fixture transactionnelle : rollback systématique -> tests totalement isolés et rapides
@pytest.fixture()
def session_isolee(engine_test):
connexion = engine_test.connect()
transaction = connexion.begin()
SessionTest = sessionmaker(bind=connexion)
session = SessionTest()
yield session
session.close()
transaction.rollback() # annule TOUT, même les commit() faits pendant le test
connexion.close()
# Override de dépendance FastAPI pour les tests d'intégration (TestClient)
from fastapi.testclient import TestClient
from app.main import app
from app.core.database import get_db
def test_endpoint_creation_utilisateur(engine_test):
SessionTest = sessionmaker(bind=engine_test)
def override_get_db():
with SessionTest() as s:
yield s
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
reponse = client.post("/utilisateurs", json={"email": "api@test.com", "nom": "API"})
assert reponse.status_code == 201
app.dependency_overrides.clear()
# Alternative : PostgreSQL éphémère via testcontainers pour des tests fidèles à la prod
# (SQLite diverge de PostgreSQL sur certains types/contraintes -- JSONB, ARRAY, etc.)Résumé
- SQLite en mémoire (
sqlite:///:memory:+StaticPool) donne des tests rapides et isolés sans dépendance externe. - Un
rollback()après chaque test (ou une transaction englobante annulée) garantit l'indépendance entre tests. app.dependency_overrides(FastAPI) permet de brancher la session de test à la place de la session de production.- SQLite diverge de PostgreSQL sur certains types avancés (
JSONB,ARRAY) : pour une fidélité totale, préférer un PostgreSQL éphémère (testcontainers) sur les tests critiques.
Exercices pratiques
Mission : un test JSONB qui ment sur son succès
Objectif : Diagnostiquer pourquoi un test passe sur SQLite mais pourrait échouer sur PostgreSQL en production, et choisir la bonne stratégie de test.
Contexte
Un test vérifie un comportement spécifique à une colonne JSONB de PostgreSQL sur le modèle Commande, et passe parfaitement sur une base SQLite en mémoire créée pour la suite de tests. Une revue de code s'interroge sur la fiabilité de ce test avant de le considérer comme une preuve suffisante pour merger la fonctionnalité en production.