Les tests. La vérification silencieuse. Avant de sortir en mission, tu contrôles chaque pièce de ton équipement. Un pistolet qui s'enraye au mauvais moment, c'est un cercueil. Un code qui casse en production, c'est une porte ouverte.
Les tests unitaires, c'est vérifier chaque pièce indépendamment. En isolation. Sous tous les angles.
16.1 Pourquoi Tester ? - La Philosophie de la Vérification
Les Tests Ne Sont Pas Pour Toi
// Ils sont pour :
// 1. Celui qui reprendra ton code dans 6 mois (peut-être toi, drogué de café)
// 2. Le système d'intégration continue (CI) qui détecte les régressions
// 3. La documentation vivante (les tests montrent comment utiliser ton code)
// 4. Ton propre sommeil (savoir que ça marche même après modifications)
// Sans tests :
// - "Ça marche sur ma machine" (famous last words)
// - Peur de toucher au code existant
// - Bugs qui ressortent comme des cadavres
// Avec tests :
// - Confiance pour refactorer
// - Détection immédiate des cassures
// - Documentation automatique
Les Types de Tests
// Tests Unitaires → Une seule fonction/classe en isolation
// Tests d'Intégration → Plusieurs modules qui collaborent
// Tests End-to-End (E2E) → L'utilisateur clique, le système répond
// Tests de Performance → Combien de temps ça prend, combien ça consomme
// Ici, on parle unitaires. Le reste, c'est pour plus tard.
16.2 Installation - L'Arsenal du Testeur
Vitest (moderne, rapide, compatible Vite)
// package.json
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage"
},
"devDependencies": {
"vitest": "^1.0.0",
"@vitest/coverage-v8": "^1.0.0"
}
}
Structure de Fichiers
src/
utils/
formatters.js # Code à tester
formatters.test.js # Tests à côté (recommandé)
__tests__/ # Ou dans un dossier dédié
formatters.test.js
Premier Test
// src/utils/math.js
export function add(a, b) {
return a + b;
}
// src/utils/math.test.js
import { describe, it, expect } from 'vitest';
import { add } from './math';
describe('add function', () => {
it('should add two numbers', () => {
const result = add(2, 3);
expect(result).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-1, 5)).toBe(4);
});
});
16.3 Tester les Types Primitifs - Les Briques de Base
Nombres
// Fonction à tester
export function calculateTax(price, rate = 0.2) {
if (price < 0) throw new Error('Price cannot be negative');
return price * rate;
}
// Tests
import { describe, it, expect } from 'vitest';
import { calculateTax } from './tax';
describe('calculateTax', () => {
it('should calculate tax for positive price', () => {
expect(calculateTax(100)).toBe(20);
expect(calculateTax(50, 0.1)).toBe(5);
});
it('should handle zero', () => {
expect(calculateTax(0)).toBe(0);
});
it('should throw for negative price', () => {
expect(() => calculateTax(-10)).toThrow();
expect(() => calculateTax(-10)).toThrow('Price cannot be negative');
});
it('should return a number', () => {
const result = calculateTax(100);
expect(typeof result).toBe('number');
expect(Number.isFinite(result)).toBe(true);
});
});
Chaînes de Caractères
export function maskName(name) {
if (!name || typeof name !== 'string') return '';
if (name.length <= 2) return '**';
const first = name[0];
const last = name[name.length - 1];
return `${first}${'*'.repeat(name.length - 2)}${last}`;
}
// Tests
describe('maskName', () => {
it('should mask middle characters', () => {
expect(maskName('Jonathan')).toBe('J******n');
expect(maskName('AB')).toBe('AB');
expect(maskName('A')).toBe('**');
});
it('should handle edge cases', () => {
expect(maskName('')).toBe('');
expect(maskName(null)).toBe('');
expect(maskName(undefined)).toBe('');
expect(maskName(123)).toBe('');
});
it('should preserve first and last letter', () => {
const masked = maskName('Christopher');
expect(masked[0]).toBe('C');
expect(masked[masked.length - 1]).toBe('r');
expect(masked).toHaveLength('Christopher'.length);
});
});
Booléens
export function isValidAgent(agent) {
return Boolean(
agent &&
agent.codename &&
agent.codename.length >= 3 &&
agent.level >= 1 &&
agent.level <= 10 &&
agent.active !== false
);
}
// Tests
describe('isValidAgent', () => {
it('should return true for valid agent', () => {
const validAgent = {
codename: 'Raven',
level: 7,
active: true
};
expect(isValidAgent(validAgent)).toBe(true);
});
it('should return false for invalid agent', () => {
expect(isValidAgent(null)).toBe(false);
expect(isValidAgent({})).toBe(false);
expect(isValidAgent({ codename: 'AB', level: 5 })).toBe(false);
expect(isValidAgent({ codename: 'Raven', level: 0 })).toBe(false);
expect(isValidAgent({ codename: 'Raven', level: 11 })).toBe(false);
expect(isValidAgent({ codename: 'Raven', level: 5, active: false })).toBe(false);
});
it('should return a boolean', () => {
const result = isValidAgent({ codename: 'Test', level: 5 });
expect(typeof result).toBe('boolean');
});
});
16.4 Tester les Types Complexes - Les Objets et Tableaux
Tableaux
export function filterActiveAgents(agents) {
if (!Array.isArray(agents)) return [];
return agents.filter(agent =>
agent &&
agent.active === true &&
agent.lastSeen &&
new Date(agent.lastSeen) > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
);
}
// Tests
describe('filterActiveAgents', () => {
const mockAgents = [
{ id: 1, active: true, lastSeen: '2024-03-15' },
{ id: 2, active: false, lastSeen: '2024-03-15' },
{ id: 3, active: true, lastSeen: '2023-01-01' }, // Trop vieux
null,
{ id: 4, active: true }, // Pas de lastSeen
];
it('should filter only active and recent agents', () => {
const result = filterActiveAgents(mockAgents);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('should handle non-array input', () => {
expect(filterActiveAgents(null)).toEqual([]);
expect(filterActiveAgents({})).toEqual([]);
expect(filterActiveAgents('test')).toEqual([]);
});
it('should not mutate original array', () => {
const original = [...mockAgents];
filterActiveAgents(original);
expect(original).toEqual(mockAgents);
});
});
Objets
export function mergeProfiles(baseProfile, updates) {
if (!baseProfile || typeof baseProfile !== 'object') {
throw new Error('Base profile must be an object');
}
// Merge profond récursif simple
const result = { ...baseProfile };
for (const [key, value] of Object.entries(updates || {})) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = mergeProfiles(result[key] || {}, value);
} else {
result[key] = value;
}
}
return result;
}
// Tests
describe('mergeProfiles', () => {
it('should deeply merge two objects', () => {
const base = {
name: 'Agent X',
details: { age: 30, level: 5 },
tags: ['alpha']
};
const updates = {
details: { level: 7, department: 'Intel' },
status: 'active'
};
const result = mergeProfiles(base, updates);
expect(result).toEqual({
name: 'Agent X',
details: { age: 30, level: 7, department: 'Intel' },
tags: ['alpha'],
status: 'active'
});
// Original non modifié
expect(base.details.level).toBe(5);
});
it('should throw for invalid base profile', () => {
expect(() => mergeProfiles(null, {})).toThrow();
expect(() => mergeProfiles('string', {})).toThrow();
});
it('should handle empty updates', () => {
const base = { a: 1 };
expect(mergeProfiles(base, {})).toEqual(base);
expect(mergeProfiles(base, null)).toEqual(base);
expect(mergeProfiles(base, undefined)).toEqual(base);
});
});
Classes
export class SecureMessage {
constructor(content, priority = 'low') {
if (!content || typeof content !== 'string') {
throw new Error('Content must be a non-empty string');
}
this.content = content;
this.priority = priority;
this.timestamp = new Date();
this.id = this._generateId();
}
_generateId() {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
encrypt() {
// Simulation simple
return `ENCRYPTED:${btoa(this.content)}`;
}
isUrgent() {
return this.priority === 'high';
}
static createUrgent(content) {
return new SecureMessage(content, 'high');
}
}
// Tests
describe('SecureMessage', () => {
describe('constructor', () => {
it('should create a message with valid content', () => {
const msg = new SecureMessage('Test message');
expect(msg.content).toBe('Test message');
expect(msg.priority).toBe('low');
expect(msg.timestamp).toBeInstanceOf(Date);
expect(msg.id).toMatch(/^msg_\d+_[a-z0-9]+$/);
});
it('should throw for invalid content', () => {
expect(() => new SecureMessage('')).toThrow();
expect(() => new SecureMessage(null)).toThrow();
expect(() => new SecureMessage(123)).toThrow();
});
});
describe('encrypt', () => {
it('should encrypt the content', () => {
const msg = new SecureMessage('Secret');
const encrypted = msg.encrypt();
expect(encrypted).toMatch(/^ENCRYPTED:/);
expect(encrypted).toContain(btoa('Secret'));
});
});
describe('isUrgent', () => {
it('should detect urgent messages', () => {
const normal = new SecureMessage('Test', 'low');
const urgent = new SecureMessage('Test', 'high');
expect(normal.isUrgent()).toBe(false);
expect(urgent.isUrgent()).toBe(true);
});
});
describe('static createUrgent', () => {
it('should create an urgent message', () => {
const urgent = SecureMessage.createUrgent('Alert!');
expect(urgent).toBeInstanceOf(SecureMessage);
expect(urgent.isUrgent()).toBe(true);
expect(urgent.content).toBe('Alert!');
});
});
});
16.5 Tester les Erreurs - Quand Ça Doit Casser
Tests d'Exceptions
export function validateAccessCode(code) {
if (!code) {
throw new Error('Access code is required');
}
if (typeof code !== 'string') {
throw new TypeError('Access code must be a string');
}
if (code.length !== 8) {
throw new RangeError('Access code must be exactly 8 characters');
}
if (!/^[A-Z0-9]+$/.test(code)) {
throw new Error('Access code must contain only uppercase letters and numbers');
}
return true;
}
// Tests
describe('validateAccessCode', () => {
it('should return true for valid code', () => {
expect(validateAccessCode('ABC123XY')).toBe(true);
});
it('should throw Error for missing code', () => {
expect(() => validateAccessCode()).toThrow('Access code is required');
expect(() => validateAccessCode(null)).toThrow('Access code is required');
expect(() => validateAccessCode('')).toThrow('Access code is required');
});
it('should throw TypeError for non-string', () => {
expect(() => validateAccessCode(12345678)).toThrow(TypeError);
expect(() => validateAccessCode(12345678)).toThrow('Access code must be a string');
});
it('should throw RangeError for wrong length', () => {
expect(() => validateAccessCode('ABC')).toThrow(RangeError);
expect(() => validateAccessCode('ABCDEFGHI')).toThrow(RangeError);
});
it('should throw Error for invalid characters', () => {
expect(() => validateAccessCode('abc123xy')).toThrow('uppercase letters');
expect(() => validateAccessCode('ABC-1234')).toThrow('uppercase letters');
expect(() => validateAccessCode('ABC 1234')).toThrow('uppercase letters');
});
});
Tests Asynchrones (Erreurs dans les Promesses)
export async function fetchAgentData(id) {
if (!id) {
throw new Error('Agent ID is required');
}
// Simulation d'appel API
const response = await fetch(`/api/agents/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch agent: ${response.status}`);
}
return response.json();
}
// Tests avec mocks (voir section suivante)
describe('fetchAgentData', () => {
it('should throw for missing ID', async () => {
await expect(fetchAgentData()).rejects.toThrow('Agent ID is required');
});
it('should throw for network error', async () => {
// Mock de fetch qui échoue
global.fetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 404 })
);
await expect(fetchAgentData('123')).rejects.toThrow('Failed to fetch agent: 404');
});
});
16.6 Mocks et Stubs - Les Doublures
Pourquoi Mocker ?
// Pour isoler le code testé :
// 1. Dépendances externes (API, base de données, système de fichiers)
// 2. Code lent (appels réseau, calculs lourds)
// 3. Comportements aléatoires (Math.random, Date.now)
// 4. Effets de bord (console.log, localStorage)
// Vitest fournit `vi` pour les mocks
Mock de Fonctions
import { vi } from 'vitest';
// Code à tester
export function processAgent(agent, logger = console.log) {
if (!agent.verified) {
logger(`Agent ${agent.id} not verified`);
return false;
}
logger(`Processing agent ${agent.id}`);
return true;
}
// Tests
describe('processAgent', () => {
it('should log and return false for unverified agent', () => {
const mockLogger = vi.fn(); // Fonction espion
const agent = { id: '007', verified: false };
const result = processAgent(agent, mockLogger);
expect(result).toBe(false);
expect(mockLogger).toHaveBeenCalledWith('Agent 007 not verified');
expect(mockLogger).toHaveBeenCalledTimes(1);
});
it('should log and return true for verified agent', () => {
const mockLogger = vi.fn();
const agent = { id: '008', verified: true };
const result = processAgent(agent, mockLogger);
expect(result).toBe(true);
expect(mockLogger).toHaveBeenCalledWith('Processing agent 008');
});
});
Mock de Modules
// externalApi.js
export async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
// processor.js
import { fetchData } from './externalApi';
export async function processUrl(url) {
const data = await fetchData(url);
return data.importantValue * 2;
}
// processor.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processUrl } from './processor';
import { fetchData } from './externalApi';
// Mock tout le module
vi.mock('./externalApi');
describe('processUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should process data from API', async () => {
// Configurer le mock
fetchData.mockResolvedValue({ importantValue: 21 });
const result = await processUrl('https://api.example.com/data');
expect(fetchData).toHaveBeenCalledWith('https://api.example.com/data');
expect(result).toBe(42);
});
it('should handle API errors', async () => {
fetchData.mockRejectedValue(new Error('Network error'));
await expect(processUrl('invalid-url')).rejects.toThrow('Network error');
});
});
Mock de fetch / API Calls
// agentService.js
export async function getAgentStatus(id) {
const response = await fetch(`/api/agents/${id}/status`);
if (!response.ok) {
throw new Error(`Status check failed: ${response.status}`);
}
return response.json();
}
// agentService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAgentStatus } from './agentService';
describe('getAgentStatus', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return status for valid agent', async () => {
const mockStatus = { active: true, lastCheck: '2024-03-15' };
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStatus)
});
const result = await getAgentStatus('007');
expect(fetch).toHaveBeenCalledWith('/api/agents/007/status');
expect(result).toEqual(mockStatus);
});
it('should throw for failed request', async () => {
fetch.mockResolvedValue({
ok: false,
status: 404
});
await expect(getAgentStatus('invalid')).rejects.toThrow('Status check failed: 404');
});
});
Mock de Date et Math.random
// codeGenerator.js
export function generateTemporaryCode() {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `TEMP_${timestamp}_${random}`;
}
// codeGenerator.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { generateTemporaryCode } from './codeGenerator';
describe('generateTemporaryCode', () => {
beforeEach(() => {
// Fixer Date.now()
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-03-15T10:30:00Z'));
// Fixer Math.random()
vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should generate predictable code with mocked values', () => {
// Date.now() = 1710498600000
// Math.random() = 0.123456789 -> toString(36) = "0.4f8z5l3i7o" -> substr(2,8) = "8z5l3i7o"
const code = generateTemporaryCode();
expect(code).toBe('TEMP_1710498600000_8z5l3i7o');
expect(code).toMatch(/^TEMP_\d+_[a-z0-9]{6}$/);
});
});
16.7 Bonnes Pratiques - Les Règles de l'Ombre
Une Assertion par Test (idéalement)
// ❌ Mauvais
it('should process agent correctly', () => {
const result = processAgent(testAgent);
expect(result.status).toBe('active');
expect(result.processedAt).toBeInstanceOf(Date);
expect(result.id).toBe(testAgent.id);
});
// ✅ Mieux (mais parfois verbeux)
describe('processAgent', () => {
it('should set status to active', () => {
const result = processAgent(testAgent);
expect(result.status).toBe('active');
});
it('should add processed timestamp', () => {
const result = processAgent(testAgent);
expect(result.processedAt).toBeInstanceOf(Date);
});
it('should preserve agent id', () => {
const result = processAgent(testAgent);
expect(result.id).toBe(testAgent.id);
});
});
Nommage des Tests
// Pattern : should [expected behavior] when [condition]
describe('validatePassword', () => {
it('should return true when password meets all requirements', () => {
// ...
});
it('should return false when password is too short', () => {
// ...
});
it('should throw error when password is null', () => {
// ...
});
});
// Ou en français si tu préfères :
describe('validatePassword', () => {
it('devrait retourner true quand le mot de passe respecte les règles', () => {
// ...
});
});
AAA Pattern : Arrange, Act, Assert
it('should calculate discount correctly', () => {
// Arrange : Préparer les données
const price = 100;
const discountRate = 0.2;
const expected = 80;
// Act : Exécuter l'action
const result = calculateDiscount(price, discountRate);
// Assert : Vérifier le résultat
expect(result).toBe(expected);
});
Tests Indépendants
describe('AgentRegistry', () => {
let registry;
beforeEach(() => {
// Chaque test a une instance fraîche
registry = new AgentRegistry();
});
it('should add agent', () => {
registry.addAgent({ id: '001' });
expect(registry.hasAgent('001')).toBe(true);
});
it('should remove agent', () => {
registry.addAgent({ id: '002' });
registry.removeAgent('002');
expect(registry.hasAgent('002')).toBe(false);
});
// Ces tests ne dépendent pas les uns des autres
});
Couverture de Code
// Vitest peut mesurer la couverture
// npm test -- --coverage
// Objectif : 70-80% c'est bien, 100% c'est souvent impossible/coûteux
// Prioriser :
// 1. Logique métier complexe
// 2. Code critique (sécurité, transactions)
// 3. Code souvent modifié
// Ignorer :
// 1. Getters/setters triviaux
// 2. Code de configuration/initialisation
// 3. Code généré automatiquement
Tests en Isolation (Sans le DOM)
// Pour tester du code pur (sans DOM), utilise vi.mock pour les APIs DOM
// ❌ Évite si possible
it('should update DOM element', () => {
document.body.innerHTML = '<div id="test"></div>';
updateElement('test', 'New content');
expect(document.getElementById('test').textContent).toBe('New content');
});
// ✅ Préfère tester la logique
it('should return new content', () => {
const result = generateContent('test');
expect(result).toBe('New content');
});
Résumé du Module 16 :
Pourquoi tester :
- Confiance dans le refactoring
- Documentation vivante
- Détection précoce des bugs
Ce qu'il faut tester :
- Types primitifs : Nombres, strings, booléens
- Types complexes : Tableaux, objets, classes
- Erreurs : Les exceptions attendues
- Comportements asynchrones : Promesses, async/await
Outils de test :
- Mocks : Pour isoler les dépendances externes
- Stubs : Pour fournir des réponses prédéfinies
- Spies : Pour vérifier les appels de fonction
Bonnes pratiques :
- Tests indépendants et idempotents
- Nommage clair des tests
- AAA Pattern (Arrange, Act, Assert)
- Couverture raisonnable (pas obsessionnelle)
Les tests, c'est comme vérifier ton équipement avant une mission.
Ça prend du temps.
Jusqu'au jour où ça te sauve la vie.