MFormations
Modern Backend Engineering

Chapitre 10

Chapitre 10 — Testing Backend

Chapitre 10 — Testing Backend

Cours — Testing Backend

1. Pyramide des Tests

        ╱╲
       ╱ E2E ╲
      ╱────────╲
     ╱Integration╲
    ╱──────────────╲
   ╱   Unit Tests   ╲
  ╱────────────────────╲

Proportion idéale

  • Unit tests : 70% — rapides, isolés, fiables
  • Integration tests : 20% — testent les interactions réelles
  • E2E tests : 10% — tests de parcours complets

Pourquoi cette pyramide ?

  • Les tests unitaires sont rapides (ms) et faciles à diagnostiquer
  • Les tests E2E sont lents (minutes) et fragiles
  • Privilégier la base de la pyramide pour un feedback rapide

2. Tests Unitaires

Caractéristiques

  • Testent une seule unité (fonction, méthode, classe)
  • Isolés des dépendances externes (DB, API, filesystem)
  • Exécution en mémoire, sans I/O
  • Déterministes (mêmes entrées = mêmes sorties)

Exemple avec Vitest (Node.js)

// math.js
export function calculateDiscount(price, code) {
  if (!code) return price;
  const discounts = { SAVE10: 0.1, SAVE20: 0.2 };
  return price * (1 - (discounts[code] || 0));
}

// math.test.js
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './math';

describe('calculateDiscount', () => {
  it('returns full price when no code', () => {
    expect(calculateDiscount(100, null)).toBe(100);
  });

  it('applies 10% discount for SAVE10', () => {
    expect(calculateDiscount(100, 'SAVE10')).toBe(90);
  });

  it('returns full price for unknown code', () => {
    expect(calculateDiscount(100, 'INVALID')).toBe(100);
  });
});

Exemple avec Pytest (Python)

# test_math.py
import pytest
from math_module import calculate_discount

def test_no_discount():
    assert calculate_discount(100, None) == 100

def test_save10():
    assert calculate_discount(100, "SAVE10") == 90

@pytest.mark.parametrize("price,code,expected", [
    (100, None, 100),
    (100, "SAVE10", 90),
    (200, "SAVE20", 160),
])
def test_discounts(price, code, expected):
    assert calculate_discount(price, code) == expected

Exemple avec go test

// math_test.go
package main

import "testing"

func TestCalculateDiscount(t *testing.T) {
    result := CalculateDiscount(100, "SAVE10")
    expected := 90.0
    if result != expected {
        t.Errorf("Expected %.1f, got %.1f", expected, result)
    }
}

3. Tests d'Intégration

Objectif

Tester l'interaction entre plusieurs composants réels :

  • Base de données
  • Cache (Redis)
  • Files d'attente
  • APIs externes

Exemple avec testcontainers

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Client } from 'pg';

describe('User Repository Integration', () => {
  let client;

  beforeAll(async () => {
    const container = await new PostgreSqlContainer().start();
    client = new Client({ connectionString: container.getConnectionUri() });
    await client.connect();
    await client.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        name VARCHAR(255)
      )
    `);
  });

  it('should create and find a user', async () => {
    await client.query(
      'INSERT INTO users (email, name) VALUES ($1, $2)',
      ['test@test.com', 'Test User']
    );
    const result = await client.query(
      'SELECT * FROM users WHERE email = $1',
      ['test@test.com']
    );
    expect(result.rows[0].name).toBe('Test User');
  });

  afterAll(async () => {
    await client.end();
  });
});

Base de données de test

  • Utiliser une vraie base de données (PostgreSQL en conteneur ou SQLite en mémoire)
  • Transaction rollback après chaque test
  • Reset complet entre les suites de tests

4. Tests de Contrat (Pact)

Principe

Vérifier que le contrat entre un fournisseur (provider) et un consommateur (consumer) est respecté.

Consumer-Driven Contracts

Consumer (Frontend) ── Pact ──▶ Provider (API)
     │                              │
     └── définit les attentes ──▶ vérifie les attentes

Exemple Pact (Node.js)

// consumer test
const { Pact } = require('@pact-foundation/pact');
const API = require('./api');

describe('User Service API', () => {
  const provider = new Pact({
    consumer: 'WebApp',
    provider: 'UserService',
    port: 1234,
  });

  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  describe('getting a user', () => {
    beforeEach(() => {
      provider.addInteraction({
        state: 'user exists',
        uponReceiving: 'a request for user',
        withRequest: { method: 'GET', path: '/users/1' },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: { id: 1, name: 'John', email: 'john@test.com' }
        }
      });
    });

    it('returns the user', async () => {
      const user = await API.getUser(1);
      expect(user.name).toBe('John');
    });
  });
});

Provider verification

// provider verification
const { Verifier } = require('@pact-foundation/pact');

describe('Pact Verification', () => {
  it('should satisfy consumer contract', async () => {
    await new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3000',
      pactUrls: ['file:///pacts/webapp-userservice.json'],
    }).verifyProvider();
  });
});

5. Tests E2E

Outils

  • Playwright / Cypress — pour les applications web
  • Supertest — pour les API REST
  • Newman — pour les collections Postman

Exemple Supertest

import request from 'supertest';
import { app } from '../app';

describe('API E2E', () => {
  it('should create and retrieve a user', async () => {
    const createRes = await request(app)
      .post('/api/users')
      .send({ name: 'John', email: 'john@test.com' })
      .expect(201);

    const getRes = await request(app)
      .get(`/api/users/${createRes.body.id}`)
      .expect(200);

    expect(getRes.body.name).toBe('John');
  });
});

6. Tests de Charge

k6

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 100 },  // ramp up
    { duration: '5m', target: 100 },  // stay
    { duration: '2m', target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% des requêtes < 500ms
    http_req_failed: ['rate<0.01'],   // < 1% d'erreurs
  }
};

export default function () {
  const res = http.get('https://api.example.com/users');
  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 300ms': (r) => r.timings.duration < 300,
  });
  sleep(1);
}

Artillery

# load-test.yml
config:
  target: "https://api.example.com"
  phases:
    - duration: 60
      arrivalRate: 10       # 10 req/s
      rampTo: 50            # monte à 50 req/s
  defaults:
    headers:
      Authorization: "Bearer {{ token }}"

scenarios:
  - flow:
      - get:
          url: "/api/users"
          capture:
            - json: "$.users[0].id"
              as: "userId"
      - get:
          url: "/api/users/{{ userId }}/posts"

7. TDD (Test-Driven Development)

Cycle Red-Green-Refactor

  1. Red : Écrire un test qui échoue
  2. Green : Écrire le minimum de code pour faire passer le test
  3. Refactor : Améliorer le code sans changer le comportement

Exemple TDD

// Étape 1 — RED : test qui échoue
describe('ShoppingCart', () => {
  it('should calculate total with tax', () => {
    const cart = new ShoppingCart();
    cart.addItem({ price: 100, quantity: 2 });
    expect(cart.totalWithTax(0.2)).toBe(240); // 200 + 20% tax
  });
});

// Étape 2 — GREEN : implémentation minimale
class ShoppingCart {
  constructor() { this.items = []; }
  addItem(item) { this.items.push(item); }
  totalWithTax(taxRate) {
    const subtotal = this.items.reduce((s, i) => s + i.price * i.quantity, 0);
    return subtotal * (1 + taxRate);
  }
}

// Étape 3 — REFACTOR : amélioration
class ShoppingCart {
  constructor() { this.items = []; }
  addItem(item) { this.items.push(item); }
  get subtotal() {
    return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
  }
  totalWithTax(taxRate) {
    return +(this.subtotal * (1 + taxRate)).toFixed(2);
  }
}

8. Mocking et Stubs

Quand mocker ?

  • Appels à des APIs externes
  • Base de données
  • Filesystem
  • Horloge système (Date.now())
  • Objets globaux (process.env)

Exemple avec Mock Service Worker (MSW)

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('https://api.github.com/users/octocat', () => {
    return HttpResponse.json({ login: 'octocat', id: 1 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('should fetch GitHub user', async () => {
  const user = await fetchGitHubUser('octocat');
  expect(user.login).toBe('octocat');
});

Exemple avec unittest.mock (Python)

from unittest.mock import Mock, patch
import pytest

def test_get_user():
    mock_db = Mock()
    mock_db.query.return_value = {"id": 1, "name": "John"}

    service = UserService(mock_db)
    user = service.get_user(1)

    mock_db.query.assert_called_once_with("SELECT * FROM users WHERE id = ?", [1])
    assert user["name"] == "John"

9. Test Coverage et Qualité

Métriques

  • Line coverage : % de lignes exécutées
  • Branch coverage : % de branches (if/else) testées
  • Function coverage : % de fonctions appelées
  • Mutation testing : mesure la qualité réelle des tests

Configuration Vitest

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'istanbul',
      reporter: ['text', 'json', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        branches: 75,
        functions: 80,
        statements: 80
      },
      exclude: ['**/*.test.js', '**/node_modules/**']
    }
  }
});

Mutation Testing (Stryker)

npx stryker run
# Mutation score: 85.7%
# 42 mutants killed, 7 survived

10. CI/CD et Tests Automatisés

GitHub Actions

name: Test Suite
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        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: 22
      - run: npm ci
      - run: npm test
      - run: npm run test:integration
      - run: npm run coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

Stratégie de parallélisation

  • Division des tests en buckets
  • Exécution parallèle sur plusieurs workers
  • Sharding dans Vitest : vitest --shard=1/4