Si vous mock votre base de données dans les tests, vous testez que votre couche repository appelle les bonnes méthodes. Vous ne testez pas que les données survivent à un crash, qu’une contrainte d’unicité bloque réellement les doublons, ou qu’une transaction fait un rollback quand quelque chose échoue.
Cette distinction compte. Un save() mocké retourne ce que vous lui dites de retourner. Un vrai save() peut entrer en deadlock, tronquer silencieusement une chaîne de caractères, ou écrire sur une réplica qui traine trois secondes derrière le primaire. Votre suite de tests sera verte pendant que vos utilisateurs perdent des données.
Pourquoi les tests de base de données mockés donnent une fausse confiance
La plupart des tests de persistance ressemblent à ceci :
// A test that verifies syntax, not physics
jest.mock('./db', () => ({
query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));
it('saves a user', async () => {
const repo = new UserRepository();
const user = await repo.create({ name: 'Ada' });
expect(user.id).toBe(1);
});
Ce test prouve que votre SQL est syntaxiquement valide et que votre mapper ne lève pas d’exception. Il ne prouve rien sur la persistance.
Les vrais dangers se cachent dans le fossé entre « la requête a été exécutée » et « les données sont durables ». Est-ce que votre ORM flush le buffer d’écriture avant de retourner ? Est-ce que votre connection pool réutilise une transaction qui a déjà fait un rollback ? Est-ce que ON CONFLICT DO NOTHING avale un insert que vous espériez voir réussir ?
Vous ne trouverez pas ces bugs avec un mock qui retourne { id: 1 } sur commande.
Le test aller-retour : le test honnête le plus simple
Le test de persistance le plus basique est aussi le plus honnête. Écrivez des données, relisez-les, vérifiez qu’elles correspondent.
import { createConnection } from './db';
it('persists and retrieves a user', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });
const found = await repo.findById('user-1');
expect(found).toEqual({
id: 'user-1',
name: 'Ada',
email: 'ada@example.com',
});
});
Cela attrape les échecs évidents : commits manquants, mauvais mappings de colonnes, bugs de sérialisation. Cela attrape aussi les plus subtils. Si votre méthode create hashe l’email avant d’écrire mais que findById ne hashe pas avant de requêter, ce test échoue. Un test mocké ne le ferait pas.
Tester les contraintes là où elles vivent
Les contraintes de base de données sont de la logique. Elles méritent des tests comme n’importe quelle autre branche de votre code.
it('enforces unique emails at the database level', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', email: 'ada@example.com' });
await expect(
repo.create({ id: 'user-2', email: 'ada@example.com' })
).rejects.toThrow(/unique constraint/i);
});
Ce test documente une règle métier au seul endroit qui l’impose réellement. Si quelqu’un supprime plus tard l’index unique pour « corriger » une migration instable, ce test hurle. Un mock laisserait tranquillement deux utilisateurs partager un email, car le mock n’a aucune notion d’index.
Le comportement des transactions n’est pas théorique
Votre application enveloppe probablement plusieurs écritures dans une transaction. Le test de cette transaction doit prouver qu’un échec partiel laisse la base de données cohérente.
it('rolls back the entire transfer on failure', async () => {
const db = await createConnection();
const accounts = new AccountRepository(db);
await accounts.create({ id: 'A', balance: 100 });
await accounts.create({ id: 'B', balance: 50 });
// Force a failure mid-transaction by violating a check constraint
const transfer = db.transaction(async (trx) => {
const acc = new AccountRepository(trx);
await acc.debit('A', 30); // succeeds
await acc.debit('B', 9999); // exceeds balance, throws
});
await expect(transfer).rejects.toThrow();
const a = await accounts.findById('A');
const b = await accounts.findById('B');
expect(a.balance).toBe(100); // rollback preserved original state
expect(b.balance).toBe(50);
});
C’est le test qui vous sauve de la page à 3 h du matin où des comptes manquent d’argent parce qu’une API en aval a timeout et que le débit a été commité mais pas le crédit. Les mocks ne peuvent pas modéliser l’isolation des transactions. Seule une vraie base de données le peut.
Isolation : la partie difficile
L’objection que tout le monde soulève est la vitesse et l’instabilité. Si chaque test touche PostgreSQL, votre suite rampera, et les tests s’interféreront entre eux.
Il y a trois réponses pratiques, et vous utiliserez probablement les trois.
Bases de données modèles. PostgreSQL et la plupart des bases de données sérieuses supportent CREATE DATABASE ... TEMPLATE. Vous initialisez une base avec votre schéma, puis vous la clonez par fichier de test en quelques millisecondes.
// test-setup.ts
import { execSync } from 'child_process';
let templateCreated = false;
export async function createIsolatedDatabase() {
if (!templateCreated) {
execSync('createdb test_template');
execSync('psql test_template -f schema.sql');
templateCreated = true;
}
const dbName = `test_${process.pid}_${Date.now()}`;
execSync(`createdb -T test_template ${dbName}`);
return connect(dbName);
}
Chaque fichier de test obtient une base de données fraîche. Aucune logique de nettoyage nécessaire. À la fin de l’exécution, supprimez les clones.
Transactions comme limites d’isolation. Si les bases de données modèles sont trop lourdes, exécutez chaque test à l’intérieur d’une transaction et faites un rollback à la fin. La plupart des clients de base de données l’exposent explicitement.
afterEach(async () => {
await db.query('ROLLBACK');
});
it('inserts a row', async () => {
await db.query('BEGIN');
await repo.create({ id: 'x' });
expect(await repo.findById('x')).toBeDefined();
// rolled back by afterEach
});
Le problème est que le code qui utilise lui-même des transactions peut entrer en conflit avec la transaction externe de rollback. Les transactions imbriquées via SAVEPOINT résolvent généralement cela, mais tous les drivers ne les gèrent pas proprement.
Testcontainers pour la parité CI. Si vos tests tournent contre SQLite en local et PostgreSQL en production, vous testez une base de données différente. Utilisez Testcontainers pour démarrer la vraie base de données dans Docker à chaque exécution CI.
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());
Cela ajoute 2 à 3 secondes à l’exécution des tests. Cela élimine toute la classe de bugs qui ne se reproduisent que sur Postgres 15 avec standard_conforming_strings = on.
Quoi mocker, et quoi ne pas mocker
Je ne prône pas zéro mock. Les mocks sont corrects aux frontières que vous ne contrôlez pas : appels API Stripe, uploads S3, serveurs SMTP. Ceux-là sont lents, coûteux et rate-limités.
Votre base de données n’est pas une frontière externe au même sens. C’est une infrastructure que vous opérez. Son comportement est déterministe. Son schéma est du code que vous avez écrit. La mocker, c’est tester contre une version fantaisiste d’un système que vous faites tourner pour de vrai.
Le compromis est la vitesse. Un test avec une vraie base de données prend 20 à 50 ms. Un test mocké prend 2 à 5 ms. Si vous avez dix mille tests de persistance, cette différence compte. La plupart des équipes n’ont pas dix mille tests de persistance. Elles en ont une centaine, et l’honnêteté vaut l’attente.
Les tests de dérive de schéma attrapent les bugs que les revues de code manquent
Il y a un autre test embarrassamment efficace que presque personne n’écrit. Après l’exécution de vos migrations, vérifiez que vos types TypeScript correspondent toujours au schéma.
import { sql } from './db';
it('types match the database schema', async () => {
const columns = await sql`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
`;
const userColumns = new Map(columns.map((c) => [c.column_name, c]));
// If someone adds a NOT NULL column without updating the type, this fails
expect(userColumns.get('email').is_nullable).toBe('NO');
expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});
C’est le test qui attrape la migration ajoutant une colonne NOT NULL par défaut à une colonne que votre TypeScript pense toujours optionnelle. C’est fastidieux à maintenir à la main, c’est pourquoi des outils comme kysely-codegen ou drizzle-kit generate existent. Mais même une petite version manuelle vous évitera un incident en production.
FAQ
Dois-je supprimer tous mes tests de base de données mockés ?
Non. Gardez les mocks pour les tests de logique pure où la base de données n’est pas pertinente, comme tester qu’un service formate correctement une chaîne de requête. Remplacez-les par des tests avec une vraie base de données partout où le comportement de persistance est le sujet.
Comment gérer les migrations dans les tests ?
Exécutez les migrations contre votre base de données modèle une fois, puis clonez. Ou exécutez-les par suite de tests si elles sont rapides. Ne sautez jamais les migrations en CI en espérant que le schéma correspondra.
Et les répliques de lecture ?
Si votre application écrit sur le primaire et lit sur la réplique, vos tests devraient aussi. Démarrez deux connexions dans Testcontainers et configurez le routage comme le fait la production. Les lectures obsolètes induites par le lag sont de vrais bugs.
Cela s’applique-t-il aux bases de données NoSQL ?
Oui. Le principe est le même. Un client MongoDB mocké ne vous dira pas si votre index composite supporte la requête à laquelle vous pensez. Un vrai le fera.
Faites un test aller-retour aujourd’hui
Choisissez la méthode de repository que vous touchez le plus souvent. Supprimez son mock. Écrivez un test qui crée une ligne, la relit, et vérifie que les champs correspondent. Chronométrez-le. S’il prend moins de 50 ms, écrivez-en un autre.
La plupart des équipes constatent que les tests de base de données réels ne sont pas la raison pour laquelle leur suite est lente. La raison est généralement de démarrer un navigateur headless pour un test unitaire, ou d’appeler une vraie API de paiement en CI, ou de ne pas paralléliser les fichiers de test. Les tests de base de données sont honnêtes, assez rapides, et ils attrapent les bugs qui vous coûtent du sommeil.