Tests fondamentaux - 04 - Tests d'intégration : tester avec une vraie base de donnees

Pourquoi mocker la DB ne suffit pas. Setup PostgreSQL avec Docker pour des tests d'intégration fiables.

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 JOIN avec 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.