Introduction
“Il faut 100% de couverture de tests !” vs “Les tests, c’est une perte de temps !”. Entre ces deux extrêmes, où se trouve la vérité ? Après avoir vu des projets paralysés par des suites de tests trop lourdes et d’autres s’effondrer faute de tests, je pense avoir trouvé quelques équilibres pragmatiques.
Les tests ne sont pas une religion, c’est un outil. Et comme tout outil, ils doivent être adaptés au contexte du projet et aux contraintes de l’équipe.
La pyramide des tests : théorie vs réalité
Le modèle théorique
La fameuse pyramide nous dit :
- Base large : beaucoup de tests unitaires (rapides, isolés)
- Milieu : quelques tests d’intégration (plus lents, plus réalistes)
- Sommet : peu de tests end-to-end (lents, fragiles, mais complets)
Adapter la pyramide au contexte
Dans la réalité, la forme optimale dépend du projet :
Projet API/Backend
E2E (5%)
Integration (30%)
Unit Tests (65%)
Application Frontend
E2E (15%)
Integration (25%)
Unit Tests (60%)
Startup en croissance rapide
E2E Critical (10%)
Happy Path Tests (40%)
Core Logic Tests (50%)
Mes règles pragmatiques
1. Commencer par les tests qui ont le plus d’impact
// Priorisez ces tests en premier
describe('Critical Business Logic', () => {
test('Payment processing calculates correct amounts', () => {
const order = {
items: [{ price: 10, quantity: 2 }],
taxRate: 0.2,
discountCode: 'SAVE10'
};
const result = calculateOrderTotal(order);
expect(result.subtotal).toBe(20);
expect(result.discount).toBe(2);
expect(result.tax).toBe(3.6);
expect(result.total).toBe(21.6);
});
});
2. Tests d’intégration pour les interactions critiques
// Tester les intégrations qui cassent souvent
describe('Database Integration', () => {
test('User creation creates profile and sends welcome email', async () => {
const userData = { email: 'test@example.com', name: 'John' };
const user = await userService.createUser(userData);
// Vérification DB
const savedUser = await db.users.findById(user.id);
expect(savedUser.email).toBe(userData.email);
// Vérification profile créé
const profile = await db.profiles.findByUserId(user.id);
expect(profile).toBeDefined();
// Vérification email dans la queue
const emailJobs = await emailQueue.getJobs();
expect(emailJobs).toHaveLength(1);
expect(emailJobs[0].data.to).toBe(userData.email);
});
});
Stratégies par type de code
Code métier : tests obligatoires
Tout ce qui touche à l’argent, aux données utilisateur critiques, ou aux règles métier complexes doit être testé.
// Logique de pricing complexe = tests exhaustifs
describe('Subscription Pricing', () => {
const testCases = [
{ plan: 'basic', users: 5, expected: 50 },
{ plan: 'basic', users: 15, expected: 125 }, // +25% au-delà de 10
{ plan: 'pro', users: 5, expected: 100 },
{ plan: 'enterprise', users: 100, expected: 800 }
];
test.each(testCases)('calculates correct price for $plan with $users users',
({ plan, users, expected }) => {
expect(calculateSubscriptionPrice(plan, users)).toBe(expected);
}
);
});
Interface utilisateur : tests sélectifs
Ne testez pas chaque bouton, concentrez-vous sur les parcours critiques.
// Test E2E pour le parcours d'achat complet
describe('Purchase Flow', () => {
test('Complete purchase journey', async () => {
// Setup
await page.goto('/products');
// Sélection produit
await page.click('[data-testid="product-123"]');
await page.click('[data-testid="add-to-cart"]');
// Checkout
await page.click('[data-testid="checkout-button"]');
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="card-number"]', '4242424242424242');
// Validation
await page.click('[data-testid="pay-button"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
// Vérification backend
const order = await db.orders.findByEmail('test@example.com');
expect(order.status).toBe('paid');
});
});
Code utilitaire : tests unitaires massifs
Les fonctions pures, les helpers, les validations : testez-les à fond.
describe('Email Validation', () => {
const validEmails = [
'user@domain.com',
'test.email+tag@example.co.uk',
'user123@test-domain.org'
];
const invalidEmails = [
'invalid-email',
'@domain.com',
'user@',
'user@domain',
''
];
test.each(validEmails)('accepts valid email: %s', (email) => {
expect(isValidEmail(email)).toBe(true);
});
test.each(invalidEmails)('rejects invalid email: %s', (email) => {
expect(isValidEmail(email)).toBe(false);
});
});
Mocking : l’art de la substitution
Quand mocker, quand ne pas mocker
À mocker systématiquement
// Services externes (APIs, paiement, email)
jest.mock('../services/paymentService', () => ({
processPayment: jest.fn().mockResolvedValue({ success: true, transactionId: '123' })
}));
// Date/Time pour la reproductibilité
jest.useFakeTimers().setSystemTime(new Date('2025-01-15'));
// Système de fichiers
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn()
}));
À ne pas mocker
// Logique métier interne
// ❌ Ne pas faire ça
jest.mock('../utils/calculateDiscount');
// Database en test d'intégration
// ❌ Ne pas faire ça non plus
jest.mock('../database/userRepository');
Patterns de mocking efficaces
Mock partiel avec des vraies données de test
// Simuler une API avec des réponses réalistes
const mockApiResponses = {
getUserProfile: {
id: 123,
name: 'John Doe',
email: 'john@example.com',
subscription: {
plan: 'pro',
status: 'active',
expiresAt: '2025-12-31'
}
},
getOrders: [
{ id: 1, total: 29.99, status: 'completed' },
{ id: 2, total: 49.99, status: 'pending' }
]
};
// Mock qui retourne des données cohérentes
jest.mock('../services/apiService', () => ({
get: jest.fn().mockImplementation((endpoint) => {
if (endpoint.includes('/users/')) return mockApiResponses.getUserProfile;
if (endpoint.includes('/orders')) return mockApiResponses.getOrders;
throw new Error(`Unexpected API call: ${endpoint}`);
})
}));
Environnement de test : la fondation invisible
Base de données de test
// Configuration Jest pour tests d'intégration
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testMatch: ['**/*.test.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/migrations/**'
]
};
// tests/setup.js - Environnement de test propre
const { Pool } = require('pg');
let testDb;
beforeAll(async () => {
// Base de test isolée
testDb = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
max: 1
});
// Migrations automatiques
await runMigrations(testDb);
});
beforeEach(async () => {
// Nettoyer entre chaque test
await testDb.query('TRUNCATE TABLE users, orders, products CASCADE');
// Données de base communes
await seedTestData(testDb);
});
afterAll(async () => {
await testDb.end();
});
Données de test cohérentes
// Factory pattern pour créer des données de test
const UserFactory = {
build: (overrides = {}) => ({
email: 'test@example.com',
name: 'Test User',
createdAt: new Date(),
...overrides
}),
create: async (overrides = {}) => {
const userData = UserFactory.build(overrides);
return await userService.create(userData);
}
};
// Usage dans les tests
describe('User Service', () => {
test('updates user profile', async () => {
const user = await UserFactory.create({ name: 'Original Name' });
await userService.updateProfile(user.id, { name: 'Updated Name' });
const updated = await userService.findById(user.id);
expect(updated.name).toBe('Updated Name');
});
});
Mesurer l’efficacité des tests
Métriques qui comptent vraiment
Au-delà de la couverture
// Script d'analyse personnalisé
const testMetrics = {
// Vitesse d'exécution
averageTestTime: calculateAverageTime(),
// Fiabilité
flakyTestsRatio: getFlakyTests().length / getAllTests().length,
// Efficacité
bugsFoundByTests: getBugsCaughtByTests(),
bugsMissedByTests: getBugsMissedByTests(),
// Maintenance
testsUpdatedPerFeature: getTestUpdateFrequency()
};
Dashboard de qualité
// Intégration avec CI pour suivi qualité
const qualityGates = {
coverage: {
statements: 80,
branches: 75,
functions: 80,
lines: 80
},
performance: {
maxTestDuration: 300, // 5 minutes max
maxSlowTests: 5 // Maximum 5 tests > 10s
},
reliability: {
maxFlakyTests: 2, // Maximum 2 tests instables
successRate: 95 // 95% de succès minimum
}
};
Debugging de tests : quand ça ne marche pas
Tests instables : les identifier et les corriger
// Script pour détecter les tests flaky
// package.json
{
"scripts": {
"test:stability": "for i in {1..10}; do npm test || exit 1; done"
}
}
// Patterns courants de tests instables
// ❌ Problème : dépendance à l'ordre d'exécution
describe('User Tests', () => {
let userId;
test('creates user', async () => {
const user = await createUser();
userId = user.id; // État partagé entre tests
});
test('updates user', async () => {
await updateUser(userId); // Peut échouer si le premier test échoue
});
});
// ✅ Solution : isolation complète
describe('User Tests', () => {
test('creates user', async () => {
const user = await createUser();
expect(user.id).toBeDefined();
});
test('updates user', async () => {
const user = await createUser(); // Chaque test indépendant
await updateUser(user.id);
// ... assertions
});
});
Performance des tests
// Identifier les tests lents
jest.setTimeout(30000); // Timeout global
// Mesurer et optimiser
describe('Slow Operations', () => {
test('bulk data processing', async () => {
const start = Date.now();
await processBulkData();
const duration = Date.now() - start;
console.log(`Test duration: ${duration}ms`);
// Fail si trop lent (CI/CD monitoring)
if (process.env.NODE_ENV === 'ci' && duration > 5000) {
throw new Error(`Test too slow: ${duration}ms`);
}
});
});
Plan d’implémentation progressive
Phase 1 : Tests critiques (Semaine 1)
- Identifier les 5 fonctions les plus critiques du projet
- Écrire des tests unitaires complets pour ces fonctions
- Mettre en place 1 test E2E pour le parcours principal
Phase 2 : Infrastructure (Semaines 2-3)
- Configurer l’environnement de test isolé
- Intégrer les tests dans CI/CD
- Créer des factories pour les données de test
Phase 3 : Couverture étendue (Mois 2)
- Tests d’intégration pour les API principales
- Tests unitaires pour toute la logique métier
- Mocking des services externes
Phase 4 : Optimisation (Mois 3)
- Analyse de performance des tests
- Élimination des tests instables
- Mise en place de métriques qualité
Conclusion
Une stratégie de test efficace n’est pas celle qui a 100% de couverture, c’est celle qui donne confiance à l’équipe pour livrer rapidement sans casser l’existant.
Les meilleurs tests sont ceux qu’on oublie : ils tournent en arrière-plan, attrapent les bugs avant la production, et ne ralentissent pas le développement.
Commencez petit, mesurez l’impact, et ajustez au fur et à mesure. Les tests doivent servir votre projet, pas le contraindre.
Quelle sera votre première étape pour améliorer votre stratégie de test ?