Tests d'Intégration

Tester les Bases de Données : Stratégies d'Intégration

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Tester les Bases de Données : Stratégies d'Intégration

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.