Tester les Bases de Données : Stratégies d'Intégration
Romain Lefebvre
4 avril 2026

Le problème des tests de bases de données
Tester du code qui interagit avec une base de données est l'un des défis les plus courants — et les plus mal gérés — en développement backend. Deux extrêmes sont fréquents : soit tout est mocké (et les tests ne valident pas grand-chose de réel), soit les tests tournent contre la base de production (catastrophique).
Il existe une voie du milieu, et c'est ce qu'on va explorer : des tests d'intégration qui touchent une vraie base de données, mais dans un environnement isolé, reproductible, et rapide.
Les quatre stratégies principales
Avant de coder, posons le cadre. Il existe quatre approches pour isoler les tests de base de données :
1. Rollback des transactions — Chaque test s'exécute dans une transaction qui est annulée en fin de test. Rapide, mais ne fonctionne pas si votre code ORM gère ses propres transactions.
2. Truncate/seed entre les tests — On vide les tables et on réinsère les données de test avant chaque test. Plus lent, mais fiable.
3. Base de données éphémère par suite — On crée une base dédiée pour la suite de tests, on la détruit après. Idéal pour les pipelines CI.
4. Testcontainers — On démarre un container Docker de base de données pour les tests. La solution moderne et portable.
Stratégie 1 : Rollback des transactions
C'est la plus rapide quand elle s'applique. Voici un exemple avec PostgreSQL et pg :
// src/test-helpers/db-transaction.ts
import { Pool, PoolClient } from 'pg';
export class TestDatabase {
private pool: Pool;
private client: PoolClient | null = null;
constructor() {
this.pool = new Pool({
host: process.env.TEST_DB_HOST || 'localhost',
port: parseInt(process.env.TEST_DB_PORT || '5432'),
database: process.env.TEST_DB_NAME || 'myapp_test',
user: process.env.TEST_DB_USER || 'postgres',
password: process.env.TEST_DB_PASSWORD || 'postgres',
});
}
async beginTransaction(): Promise<PoolClient> {
this.client = await this.pool.connect();
await this.client.query('BEGIN');
return this.client;
}
async rollback(): Promise<void> {
if (this.client) {
await this.client.query('ROLLBACK');
this.client.release();
this.client = null;
}
}
async close(): Promise<void> {
await this.pool.end();
}
}
// src/__tests__/user.repository.test.ts
import { TestDatabase } from '../test-helpers/db-transaction';
import { UserRepository } from '../repositories/user.repository';
describe('UserRepository', () => {
let testDb: TestDatabase;
let userRepo: UserRepository;
beforeAll(() => {
testDb = new TestDatabase();
});
beforeEach(async () => {
const client = await testDb.beginTransaction();
userRepo = new UserRepository(client);
});
afterEach(async () => {
await testDb.rollback(); // Annule toutes les écritures
});
afterAll(async () => {
await testDb.close();
});
it('crée un utilisateur avec un id généré', async () => {
const user = await userRepo.create({
email: '[email protected]',
name: 'Test User',
});
expect(user.id).toBeDefined();
expect(user.email).toBe('[email protected]');
expect(user.createdAt).toBeInstanceOf(Date);
});
it('trouve un utilisateur par email', async () => {
// Arrange — créer d'abord l'utilisateur dans cette transaction
await userRepo.create({ email: '[email protected]', name: 'Alice' });
// Act
const found = await userRepo.findByEmail('[email protected]');
// Assert
expect(found).not.toBeNull();
expect(found!.name).toBe('Alice');
});
it('retourne null pour un email inexistant', async () => {
const found = await userRepo.findByEmail('[email protected]');
expect(found).toBeNull();
});
});
Stratégie 2 : Testcontainers
Testcontainers est devenu la référence pour les tests d'intégration modernes. Il démarre un vrai container Docker de votre base de données, le configure, et le détruit après les tests.
npm install --save-dev @testcontainers/postgresql
// src/__tests__/integration/product.repository.test.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { runMigrations } from '../../db/migrations';
import { ProductRepository } from '../../repositories/product.repository';
describe('ProductRepository (Testcontainers)', () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
let repo: ProductRepository;
// Démarre le container une fois pour toute la suite (plus rapide)
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
pool = new Pool({
connectionString: container.getConnectionUri(),
});
// Appliquer les migrations sur la base de test
await runMigrations(pool);
}, 60_000); // Timeout de 60s pour le pull Docker
afterAll(async () => {
await pool.end();
await container.stop();
});
beforeEach(async () => {
// Vider les tables avant chaque test
await pool.query('TRUNCATE TABLE products CASCADE');
repo = new ProductRepository(pool);
});
it('sauvegarde et récupère un produit', async () => {
const created = await repo.create({
name: 'Clavier mécanique',
price: 89.99,
stock: 42,
category: 'peripheriques',
});
expect(created.id).toBeDefined();
const found = await repo.findById(created.id);
expect(found).toMatchObject({
name: 'Clavier mécanique',
price: 89.99,
stock: 42,
});
});
it('liste les produits par catégorie', async () => {
await repo.create({ name: 'Souris', price: 29.99, stock: 10, category: 'peripheriques' });
await repo.create({ name: 'Écran', price: 299.99, stock: 5, category: 'ecrans' });
await repo.create({ name: 'Clavier', price: 59.99, stock: 20, category: 'peripheriques' });
const peripheriques = await repo.findByCategory('peripheriques');
expect(peripheriques).toHaveLength(2);
expect(peripheriques.map((p) => p.name)).toEqual(
expect.arrayContaining(['Souris', 'Clavier'])
);
});
it('met à jour le stock atomiquement', async () => {
const product = await repo.create({ name: 'Widget', price: 5.99, stock: 100, category: 'misc' });
// Simule deux commandes simultanées
await Promise.all([
repo.decrementStock(product.id, 10),
repo.decrementStock(product.id, 5),
]);
const updated = await repo.findById(product.id);
expect(updated!.stock).toBe(85); // 100 - 10 - 5
});
});
Gérer les migrations dans les tests
Un piège classique : les tests supposent un schéma qui ne correspond plus à la réalité. La solution est d'appliquer les vraies migrations sur la base de test :
// src/db/migrations.ts
import { Pool } from 'pg';
import fs from 'fs/promises';
import path from 'path';
export async function runMigrations(pool: Pool): Promise<void> {
// Créer la table de suivi des migrations
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT NOW()
)
`);
// Lire et appliquer les migrations dans l'ordre
const migrationsDir = path.join(__dirname, 'migrations');
const files = (await fs.readdir(migrationsDir))
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
const version = path.basename(file, '.sql');
const { rows } = await pool.query(
'SELECT version FROM schema_migrations WHERE version = $1',
[version]
);
if (rows.length === 0) {
const sql = await fs.readFile(path.join(migrationsDir, file), 'utf8');
await pool.query(sql);
await pool.query(
'INSERT INTO schema_migrations (version) VALUES ($1)',
[version]
);
console.log(`Migration appliquée : ${version}`);
}
}
}
export async function rollbackMigrations(pool: Pool): Promise<void> {
// Pour les tests : remet à zéro complètement
await pool.query('DROP SCHEMA public CASCADE');
await pool.query('CREATE SCHEMA public');
}
Données de test avec des factories
Les fixtures statiques deviennent vite un enfer à maintenir. Les factories sont bien plus flexibles :
// src/test-helpers/factories.ts
import { faker } from '@faker-js/faker/locale/fr';
import { Pool } from 'pg';
interface UserData {
email?: string;
name?: string;
role?: 'user' | 'admin';
createdAt?: Date;
}
export function buildUser(overrides: UserData = {}) {
return {
email: faker.internet.email(),
name: faker.person.fullName(),
role: 'user' as const,
createdAt: new Date(),
...overrides,
};
}
export async function createUser(pool: Pool, overrides: UserData = {}) {
const data = buildUser(overrides);
const { rows } = await pool.query(
`INSERT INTO users (email, name, role, created_at)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[data.email, data.name, data.role, data.createdAt]
);
return rows[0];
}
// Créer plusieurs utilisateurs en une fois
export async function createUsers(pool: Pool, count: number, overrides: UserData = {}) {
return Promise.all(
Array.from({ length: count }, () => createUser(pool, overrides))
);
}
// Utilisation dans les tests
describe('UserService.getPaginatedUsers', () => {
it('pagine correctement les résultats', async () => {
await createUsers(pool, 25); // 25 utilisateurs aléatoires
const page1 = await service.getPaginatedUsers({ page: 1, limit: 10 });
const page3 = await service.getPaginatedUsers({ page: 3, limit: 10 });
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page3.data).toHaveLength(5); // 25 - 20 = 5 restants
});
});
Tester les transactions et la cohérence
Les transactions sont souvent le point le plus difficile à tester. Voici comment vérifier qu'elles fonctionnent correctement :
describe('OrderService.placeOrder', () => {
it('débite le stock et crée la commande de façon atomique', async () => {
// Arrange
const product = await createProduct(pool, { stock: 5 });
const user = await createUser(pool);
// Act
const order = await orderService.placeOrder({
userId: user.id,
productId: product.id,
quantity: 3,
});
// Assert — la commande est créée
expect(order.status).toBe('confirmed');
// Assert — le stock est diminué
const updatedProduct = await productRepo.findById(product.id);
expect(updatedProduct!.stock).toBe(2);
});
it('n\'applique aucun changement si le stock est insuffisant', async () => {
// Arrange
const product = await createProduct(pool, { stock: 2 });
const user = await createUser(pool);
// Act
await expect(
orderService.placeOrder({
userId: user.id,
productId: product.id,
quantity: 5, // Plus que le stock disponible
})
).rejects.toThrow('Stock insuffisant');
// Assert — le stock est inchangé (rollback)
const unchangedProduct = await productRepo.findById(product.id);
expect(unchangedProduct!.stock).toBe(2);
});
});
Configuration CI/CD
Pour que ces tests fonctionnent en CI, vous avez besoin d'un service PostgreSQL ou Docker :
# .github/workflows/tests.yml
name: Tests d'intégration
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Tests d'intégration
env:
TEST_DB_HOST: localhost
TEST_DB_PORT: 5432
TEST_DB_NAME: testdb
TEST_DB_USER: testuser
TEST_DB_PASSWORD: testpass
run: npm run test:integration
Si vous utilisez Testcontainers, Docker est déjà disponible sur les runners GitHub Actions — aucune configuration services nécessaire.
Conclusion
Tester les bases de données efficacement demande un peu d'investissement initial en infrastructure de test, mais c'est l'une des valeurs ajoutées les plus importantes que vous puissiez apporter à votre codebase. Un bug de requête SQL ou de concurrence détecté par un test d'intégration coûte bien moins cher qu'un incident en production à minuit.
Choisissez votre stratégie selon vos contraintes : rollback transactionnel pour la vitesse quand c'est possible, Testcontainers pour la portabilité et le réalisme. Dans les deux cas, les factories de données rendent vos tests lisibles et maintenables.
