04 - Tests d'intégration : tester avec une vraie base de donnees
Ce que tu vas apprendre
- Pourquoi mocker la base de donnees est une fausse bonne idee
- Comment configurer PostgreSQL dans Docker pour les tests
- Écrire des tests d'intégration propres avec
bun test - Gerer le nettoyage des donnees entre les tests
Prerequisites
Avoir Docker installe. Avoir lu les articles précédents de la serie.
Pourquoi mocker la DB ne suffit pas
J'ai longtemps mocke mes repositories dans les tests. "Pas besoin de PostgreSQL, un mock suffit." Jusqu'au jour ou j'ai déployé un changement sur paltemps.fr et que la query SQL plantait en prod. Le mock retournait toujours les bonnes donnees. La vraie DB, non.
Voici ce qu'un mock de base de donnees ne teste pas :
- Les erreurs de syntaxe SQL
- Les contraintes
UNIQUE,NOT NULL,CHECK - Le comportement de
JOINavec des donnees reelles - Les migrations qui ajoutent ou suppriment des colonnes
- Les types PostgreSQL (
jsonb,timestamp with time zone,uuid) - Les index qui changent l'ordre des résultats
Quand tu mockes findByEmail pour qu'il retourne un user, tu ne testes pas que ta query SELECT * FROM users WHERE email = $1 fonctionne. Tu testes que ton code appelle une fonction. C'est tres différent.
Setup : PostgreSQL dans Docker
Un docker-compose.test.yml minimaliste :
yaml# docker-compose.test.yml
services:
postgres-test:
image: postgres:16
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
Le tmpfs est un truc que j'adore : la DB tourne en mémoire. Pas d'écriture disque, donc c'est rapide. Et tout disparaît quand le container s'arrêté. Parfait pour les tests.
bash# Demarrer la DB de test
docker compose -f docker-compose.test.yml up -d
# Lancer les tests
DATABASE_URL="postgresql://test:test@localhost:5433/testdb" bun test
# Arreter
docker compose -f docker-compose.test.yml down
Le helper de connexion
typescript// src/test/db-helper.ts
import { Pool } from "pg";
let pool: Pool | null = null;
export async function getTestDB(): Promise<Pool> {
if (!pool) {
pool = new Pool({
connectionString:
process.env.DATABASE_URL || "postgresql://test:test@localhost:5433/testdb",
});
}
return pool;
}
export async function closeTestDB(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}
export async function cleanDB(db: Pool): Promise<void> {
await db.query(`
TRUNCATE orders, order_items, users
RESTART IDENTITY CASCADE
`);
}
TRUNCATE ... RESTART IDENTITY CASCADE vide les tables et remet les sequences a 1. Le CASCADE gere les foreign keys. C'est rapide, propre, et déterministe.
Un test d'intégration complet
typescript// src/infra/pg-order-repository.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test";
import { Pool } from "pg";
import { getTestDB, closeTestDB, cleanDB } from "../test/db-helper";
import { PgOrderRepository } from "./pg-order-repository";
import { Order } from "../domain/order";
describe("PgOrderRepository", () => {
let db: Pool;
let repo: PgOrderRepository;
beforeAll(async () => {
db = await getTestDB();
// Lancer les migrations si necessaire
await db.query(`
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_email TEXT NOT NULL,
total INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
repo = new PgOrderRepository(db);
});
beforeEach(async () => {
await db.query("TRUNCATE orders RESTART IDENTITY CASCADE");
});
afterAll(async () => {
await closeTestDB();
});
it("saves and retrieves an order", async () => {
const order = Order.create({
items: [{ product: "Widget", qty: 2, price: 10 }],
customerEmail: "alice@test.com",
});
await repo.save(order);
const found = await repo.findById(order.id);
expect(found).not.toBeNull();
expect(found!.total).toBe(20);
expect(found!.customerEmail).toBe("alice@test.com");
});
it("returns null for non-existent order", async () => {
const found = await repo.findById("00000000-0000-0000-0000-000000000000");
expect(found).toBeNull();
});
it("finds orders by email", async () => {
await repo.save(Order.create({
items: [{ product: "A", qty: 1, price: 10 }],
customerEmail: "alice@test.com",
}));
await repo.save(Order.create({
items: [{ product: "B", qty: 1, price: 20 }],
customerEmail: "bob@test.com",
}));
await repo.save(Order.create({
items: [{ product: "C", qty: 1, price: 30 }],
customerEmail: "alice@test.com",
}));
const aliceOrders = await repo.findByEmail("alice@test.com");
expect(aliceOrders).toHaveLength(2);
});
});
Chaque test part d'une base vide grace au TRUNCATE dans beforeEach. Pas de dépendance entre les tests. L'ordre d'exécution n'a pas d'importance.
Tester les contraintes
Un des gros avantages des tests d'intégration : tu verifies que ta DB rejette les donnees invalides.
typescriptit("rejects duplicate emails with unique constraint", async () => {
await db.query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)
`);
await db.query("INSERT INTO users (email, name) VALUES ($1, $2)", [
"alice@test.com",
"Alice",
]);
const duplicate = db.query("INSERT INTO users (email, name) VALUES ($1, $2)", [
"alice@test.com",
"Bob",
]);
expect(duplicate).rejects.toThrow(/unique/i);
});
it("rejects null customer_email", async () => {
const insert = db.query(
"INSERT INTO orders (total, status) VALUES ($1, $2)",
[100, "PENDING"]
);
expect(insert).rejects.toThrow(/not-null/i);
});
Un mock ne t'aurait jamais dit que tu as oublie un NOT NULL. La vraie DB, si.
Tester les migrations
Si tu utilises un outil de migration (Prisma, Drizzle, sql-migrate), teste que les migrations passent sur une base vide :
typescriptdescribe("migrations", () => {
it("applies all migrations on empty database", async () => {
// Creer une DB temporaire
await db.query("CREATE DATABASE migration_test");
const tempDb = new Pool({
connectionString: "postgresql://test:test@localhost:5433/migration_test",
});
try {
// Lancer toutes les migrations
await runMigrations(tempDb);
// Verifier qu'une table existe
const result = await tempDb.query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'orders'
`);
expect(result.rows).toHaveLength(1);
} finally {
await tempDb.end();
await db.query("DROP DATABASE migration_test");
}
});
});
Stratégies de nettoyage
Trois approches, du plus simple au plus sophistique :
1. TRUNCATE dans beforeEach (recommande)
typescriptbeforeEach(async () => {
await cleanDB(db);
});
Simple, fiable, assez rapide. C'est ce que j'utilise 90% du temps.
2. Transaction rollback
typescriptlet client: PoolClient;
beforeEach(async () => {
client = await db.connect();
await client.query("BEGIN");
});
afterEach(async () => {
await client.query("ROLLBACK");
client.release();
});
Chaque test tourne dans une transaction qui est annulee a la fin. Rien n'est écrit. C'est la méthode la plus rapide, mais elle ne fonctionne pas si ton code gere ses propres transactions.
3. DB par test (lourd)
Creer une base de donnees par test. Utile pour les tests de migration, mais trop lent pour le quotidien.
Organiser les tests d'intégration
Je séparé toujours les tests unitaires des tests d'intégration :
src/
domain/
order.ts
order.test.ts # unitaire, pas de DB
infra/
pg-order-repository.ts
pg-order-repository.test.ts # integration, besoin de Docker
test/
db-helper.ts
Pour lancer uniquement les unitaires (rapides) :
bashbun test src/domain/
Pour lancer les intégration (besoin de Docker) :
bashbun test src/infra/
Ca permet de lancer les unitaires a chaque save et les intégration avant chaque commit.
Article précédent : 03 - Mocks, stubs, fakes et spies Article suivant : 05 - Tests fonctionnels