Tests Unitaires : Bonnes Pratiques pour un Code Fiable
Les tests unitaires sont la fondation d'une base de code robuste et maintenable. Ce guide vous accompagne dans l'écriture de tests efficaces qui apportent une réelle valeur.
Pourquoi les Tests Unitaires ?
Bénéfices Concrets
- Détection précoce des bugs avant la production
- Documentation vivante du comportement attendu
- Refactoring sécurisé avec confiance
- Réduction des coûts de maintenance
- Amélioration du design du code
Coût vs Valeur
// Sans tests : coût d'un bug en production
const productionBugCost = developmentTime * 10 + customerImpact + reputationDamage;
// Avec tests : coût préventif
const testingCost = developmentTime * 1.2 + testMaintenance;
// ROI = (productionBugCost - testingCost) / testingCost
Anatomie d'un Bon Test
Pattern AAA (Arrange, Act, Assert)
// ✅ Test bien structuré
describe('UserService', () => {
describe('createUser', () => {
it('should create a user with valid data', async () => {
// Arrange - Préparer les données et mocks
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 25
};
const mockRepository = {
save: jest.fn().mockResolvedValue({ id: 1, ...userData }),
findByEmail: jest.fn().mockResolvedValue(null)
};
const userService = new UserService(mockRepository);
// Act - Exécuter l'action à tester
const result = await userService.createUser(userData);
// Assert - Vérifier le résultat
expect(result).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com',
age: 25
});
expect(mockRepository.save).toHaveBeenCalledWith(userData);
expect(mockRepository.findByEmail).toHaveBeenCalledWith('john@example.com');
});
});
});
Tests Paramétrés avec describe.each
// ✅ Tests paramétrés pour plusieurs scénarios
describe.each([
{ input: '', expected: false, description: 'empty string' },
{ input: 'invalid-email', expected: false, description: 'missing @' },
{ input: 'test@', expected: false, description: 'missing domain' },
{ input: 'test@example.com', expected: true, description: 'valid email' },
{ input: 'user+tag@domain.co.uk', expected: true, description: 'complex valid email' }
])('Email validation', ({ input, expected, description }) => {
it(`should return ${expected} for ${description}`, () => {
const result = validateEmail(input);
expect(result).toBe(expected);
});
});
Techniques de Mocking Avancées
1. Dependency Injection et Mocking
// Service à tester
class OrderService {
constructor(paymentService, emailService, inventoryService) {
this.paymentService = paymentService;
this.emailService = emailService;
this.inventoryService = inventoryService;
}
async processOrder(order) {
// Vérifier le stock
const hasStock = await this.inventoryService.checkStock(order.productId, order.quantity);
if (!hasStock) {
throw new Error('Insufficient stock');
}
// Traiter le paiement
const payment = await this.paymentService.processPayment({
amount: order.total,
card: order.card
});
// Mettre à jour le stock
await this.inventoryService.reduceStock(order.productId, order.quantity);
// Envoyer confirmation
await this.emailService.sendConfirmation(order.customerEmail, {
orderId: payment.transactionId,
amount: order.total
});
return {
orderId: payment.transactionId,
status: 'confirmed',
amount: order.total
};
}
}
// Tests avec mocks complets
describe('OrderService', () => {
let orderService;
let mockPaymentService;
let mockEmailService;
let mockInventoryService;
beforeEach(() => {
mockPaymentService = {
processPayment: jest.fn()
};
mockEmailService = {
sendConfirmation: jest.fn()
};
mockInventoryService = {
checkStock: jest.fn(),
reduceStock: jest.fn()
};
orderService = new OrderService(
mockPaymentService,
mockEmailService,
mockInventoryService
);
});
describe('processOrder', () => {
const validOrder = {
productId: 'prod-123',
quantity: 2,
total: 99.99,
card: '4111111111111111',
customerEmail: 'customer@example.com'
};
it('should process order successfully with valid data', async () => {
// Arrange
mockInventoryService.checkStock.mockResolvedValue(true);
mockPaymentService.processPayment.mockResolvedValue({
transactionId: 'tx-456',
status: 'success'
});
mockInventoryService.reduceStock.mockResolvedValue(true);
mockEmailService.sendConfirmation.mockResolvedValue(true);
// Act
const result = await orderService.processOrder(validOrder);
// Assert
expect(result).toEqual({
orderId: 'tx-456',
status: 'confirmed',
amount: 99.99
});
// Vérifier les appels dans l'ordre
expect(mockInventoryService.checkStock).toHaveBeenCalledWith('prod-123', 2);
expect(mockPaymentService.processPayment).toHaveBeenCalledWith({
amount: 99.99,
card: '4111111111111111'
});
expect(mockInventoryService.reduceStock).toHaveBeenCalledWith('prod-123', 2);
expect(mockEmailService.sendConfirmation).toHaveBeenCalledWith(
'customer@example.com',
{ orderId: 'tx-456', amount: 99.99 }
);
});
it('should throw error when insufficient stock', async () => {
// Arrange
mockInventoryService.checkStock.mockResolvedValue(false);
// Act & Assert
await expect(orderService.processOrder(validOrder))
.rejects.toThrow('Insufficient stock');
// Vérifier que les autres services ne sont pas appelés
expect(mockPaymentService.processPayment).not.toHaveBeenCalled();
expect(mockInventoryService.reduceStock).not.toHaveBeenCalled();
expect(mockEmailService.sendConfirmation).not.toHaveBeenCalled();
});
});
});
2. Mocking Avancé avec Jest
// Mock de modules externes
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
// Mock de fonctions avec implémentation
const mockFunction = jest.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first call')
.mockResolvedValue('async result');
// Mock d'une classe
jest.mock('../services/DatabaseService');
const MockedDatabase = DatabaseService as jest.MockedClass<typeof DatabaseService>;
beforeEach(() => {
MockedDatabase.mockClear();
MockedDatabase.prototype.find.mockClear();
});
// Spy sur des méthodes existantes
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
afterAll(() => {
consoleSpy.mockRestore();
});
Testing de Code Asynchrone
1. Promises et Async/Await
// ✅ Tests async propres
describe('AsyncUserService', () => {
it('should handle async user creation', async () => {
const userData = { name: 'John', email: 'john@test.com' };
const result = await userService.createUser(userData);
expect(result).toBeDefined();
expect(result.id).toBeTruthy();
});
it('should reject invalid user data', async () => {
const invalidData = { name: '', email: 'invalid' };
await expect(userService.createUser(invalidData))
.rejects.toThrow('Invalid user data');
});
it('should handle database errors gracefully', async () => {
mockRepository.save.mockRejectedValue(new Error('Database connection failed'));
await expect(userService.createUser(userData))
.rejects.toThrow('Database connection failed');
});
});
2. Testing des Timers
// Fonctions utilisant setTimeout
class NotificationService {
scheduleReminder(callback, delay) {
setTimeout(() => {
callback('Reminder: Check your tasks!');
}, delay);
}
scheduleRecurring(callback, interval) {
return setInterval(() => {
callback('Recurring notification');
}, interval);
}
}
// Tests avec fake timers
describe('NotificationService', () => {
let notificationService;
beforeEach(() => {
jest.useFakeTimers();
notificationService = new NotificationService();
});
afterEach(() => {
jest.useRealTimers();
});
it('should execute callback after specified delay', () => {
const mockCallback = jest.fn();
notificationService.scheduleReminder(mockCallback, 5000);
// Rien ne devrait être appelé immédiatement
expect(mockCallback).not.toHaveBeenCalled();
// Avancer le temps de 5 secondes
jest.advanceTimersByTime(5000);
expect(mockCallback).toHaveBeenCalledWith('Reminder: Check your tasks!');
});
it('should execute recurring callback multiple times', () => {
const mockCallback = jest.fn();
const intervalId = notificationService.scheduleRecurring(mockCallback, 1000);
// Avancer le temps et vérifier les appels
jest.advanceTimersByTime(3000);
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith('Recurring notification');
clearInterval(intervalId);
});
});
Tests d'Intégration et de Bout en Bout
1. Tests d'Intégration de Base de Données
// Configuration de test avec base de données de test
const { Pool } = require('pg');
const fs = require('fs');
describe('UserRepository Integration Tests', () => {
let pool;
let userRepository;
beforeAll(async () => {
// Configuration de la DB de test
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL
});
// Setup du schéma
const schema = fs.readFileSync('./migrations/001_create_users.sql', 'utf8');
await pool.query(schema);
userRepository = new UserRepository(pool);
});
beforeEach(async () => {
// Nettoyer la DB avant chaque test
await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE');
});
afterAll(async () => {
await pool.end();
});
it('should create and retrieve a user', async () => {
// Arrange
const userData = {
name: 'Integration Test User',
email: 'integration@test.com'
};
// Act
const createdUser = await userRepository.create(userData);
const retrievedUser = await userRepository.findById(createdUser.id);
// Assert
expect(retrievedUser).toEqual(expect.objectContaining(userData));
expect(retrievedUser.id).toBeTruthy();
expect(retrievedUser.createdAt).toBeInstanceOf(Date);
});
});
2. Tests API avec Supertest
const request = require('supertest');
const app = require('../app');
describe('API Endpoints', () => {
describe('POST /api/users', () => {
it('should create a new user', async () => {
const userData = {
name: 'Test User',
email: 'test@example.com'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toEqual({
id: expect.any(Number),
name: 'Test User',
email: 'test@example.com',
createdAt: expect.any(String)
});
});
it('should validate required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({})
.expect(400);
expect(response.body.error).toContain('validation failed');
});
it('should handle duplicate email', async () => {
const userData = {
name: 'Test User',
email: 'duplicate@test.com'
};
// Premier utilisateur
await request(app)
.post('/api/users')
.send(userData)
.expect(201);
// Tentative de doublon
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409);
expect(response.body.error).toContain('already exists');
});
});
});
Test-Driven Development (TDD)
Cycle Red-Green-Refactor
// 1. RED - Écrire un test qui échoue
describe('Calculator', () => {
it('should add two numbers correctly', () => {
const calculator = new Calculator();
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
});
// 2. GREEN - Écrire le minimum de code pour faire passer le test
class Calculator {
add(a, b) {
return a + b;
}
}
// 3. REFACTOR - Améliorer le code tout en gardant les tests verts
class Calculator {
add(a, b) {
this.validateNumbers(a, b);
return a + b;
}
validateNumbers(...numbers) {
numbers.forEach(num => {
if (typeof num !== 'number' || isNaN(num)) {
throw new Error('Invalid number provided');
}
});
}
}
// Ajouter plus de tests
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add', () => {
it('should add two positive numbers', () => {
expect(calculator.add(2, 3)).toBe(5);
});
it('should add negative numbers', () => {
expect(calculator.add(-2, -3)).toBe(-5);
});
it('should handle decimal numbers', () => {
expect(calculator.add(0.1, 0.2)).toBeCloseTo(0.3);
});
it('should throw error for invalid input', () => {
expect(() => calculator.add('2', 3)).toThrow('Invalid number provided');
expect(() => calculator.add(2, null)).toThrow('Invalid number provided');
});
});
});
Coverage et Métriques
1. Configuration de Coverage avec Jest
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/config/**',
'!src/migrations/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/services/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
};
2. Analyse de Coverage
# Générer un rapport détaillé
npm test -- --coverage --watchAll=false
# Ouvrir le rapport HTML
open coverage/lcov-report/index.html
# Coverage par fichier
Jest Coverage Report
------------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
----------|---------|----------|---------|---------|----------------
All files | 85.5 | 78.2 | 89.1 | 85.3 |
services/ | 92.3 | 85.7 | 94.4 | 92.1 |
utils/ | 76.8 | 68.9 | 81.2 | 76.5 | 45-52, 78-82
Bonnes Pratiques
1. Nommage et Organisation
// ✅ Nommage descriptif
describe('UserService', () => {
describe('when creating a user', () => {
describe('with valid data', () => {
it('should return the created user with an ID', () => {});
it('should save the user to the database', () => {});
});
describe('with invalid email', () => {
it('should throw ValidationError', () => {});
it('should not save anything to database', () => {});
});
});
});
// ✅ Tests isolés et déterministes
beforeEach(() => {
// Reset tous les mocks
jest.clearAllMocks();
// État initial propre
database.clear();
cache.flush();
});
2. Tests Lisibles et Maintenables
// ✅ Factory pattern pour les données de test
class UserFactory {
static create(overrides = {}) {
return {
name: 'John Doe',
email: 'john@example.com',
age: 25,
isActive: true,
...overrides
};
}
static createMany(count, overrides = {}) {
return Array.from({ length: count }, (_, i) =>
this.create({ ...overrides, email: `user${i}@test.com` })
);
}
}
// Usage
it('should handle multiple users', async () => {
const users = UserFactory.createMany(3, { isActive: true });
await userService.createBatch(users);
const activeUsers = await userService.findActive();
expect(activeUsers).toHaveLength(3);
});
3. Éviter les Anti-Patterns
// ❌ Test trop complexe
it('should handle complex user workflow', async () => {
// 50 lignes de setup...
// Test de 10 choses différentes...
});
// ✅ Tests simples et focalisés
it('should create user', async () => {
const user = await userService.create(validUserData);
expect(user.id).toBeTruthy();
});
it('should validate user email', () => {
expect(() => userService.validateEmail('invalid'))
.toThrow('Invalid email');
});
// ❌ Tests fragiles liés à l'implémentation
it('should call database twice', () => {
userService.createUser(userData);
expect(mockDb.query).toHaveBeenCalledTimes(2);
});
// ✅ Tests focalisés sur le comportement
it('should create user and return result', async () => {
const result = await userService.createUser(userData);
expect(result).toEqual(expectedUser);
});
Conclusion
Les tests unitaires efficaces reposent sur :
- Tests simples et focalisés sur un comportement spécifique
- Isolation complète grâce aux mocks appropriés
- Couverture pertinente plutôt que maximale
- Maintenance continue avec refactoring régulier
- TDD pour guider le design du code
Un investissement initial dans une stratégie de test solide se traduit par :
- Réduction significative des bugs en production
- Confiance pour refactorer et faire évoluer le code
- Documentation vivante du comportement attendu
- Feedback rapide pendant le développement
La clé est de voir les tests comme un investissement, pas comme un coût.
Top comments (0)