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
- Red : Écrire un test qui échoue
- Green : Écrire le minimum de code pour faire passer le test
- 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