Écrire des tests unitaires efficacement
Guide pratique pour écrire des tests unitaires efficaces : bonnes pratiques, frameworks modernes (Jest, Pytest), mocks et TDD. Approche pragmatique avec exemples concrets.
Sommaire
- Introduction
- Pourquoi écrire des tests unitaires ?
- Les fondamentaux
- Exemples pratiques
- Techniques avancées
- TDD : Test-Driven Development
- Bonnes pratiques
- FAQ
Introduction
Les tests unitaires sont souvent perçus comme une corvée qui ralentit le développement. Pourtant, bien maîtrisés, ils accélèrent le développement et améliorent drastiquement la qualité du code.
Dans cet article, nous allons voir comment écrire des tests unitaires efficaces avec une approche pragmatique, en se concentrant sur JavaScript et Python.
Pourquoi écrire des tests unitaires ?
✅ Sécurité : Détection immédiate des régressions ✅ Vitesse : Refactoring sans crainte ✅ Documentation : Les tests documentent le comportement attendu ✅ Design : Force à concevoir du code modulaire ✅ Sérénité : Déploiement en confiance
Les fondamentaux
Qu’est-ce qu’un bon test unitaire ?
Un test unitaire de qualité est :
- Rapide (< 1ms)
- Isolé (indépendant des autres tests)
- Répétable (même résultat à chaque exécution)
- Lisible (compréhensible sans documentation)
- Spécifique (teste une seule fonctionnalité)
Structure AAA (Arrange, Act, Assert)
test('should calculate total price with tax', () => {
// Arrange - Préparation
const price = 100;
const taxRate = 0.2;
// Act - Action
const result = calculateTotalPrice(price, taxRate);
// Assert - Vérification
expect(result).toBe(120);
});
Exemples pratiques
Tests JavaScript avec Jest
Installation :
npm install --save-dev jest
Configuration package.json :
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}
}
Fonction à tester :
// utils/calculator.js
export function add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Les paramètres doivent être des nombres');
}
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Division par zéro interdite');
}
return a / b;
}
export class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item, quantity = 1) {
const existingItem = this.items.find(i => i.id === item.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ ...item, quantity });
}
}
getTotal() {
return this.items.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0);
}
clear() {
this.items = [];
}
}
Tests correspondants :
// __tests__/calculator.test.js
import { add, divide, ShoppingCart } from '../utils/calculator.js';
describe('Calculator Functions', () => {
describe('add', () => {
test('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('should add negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
test('should throw error for non-number inputs', () => {
expect(() => add('2', 3)).toThrow('Les paramètres doivent être des nombres');
expect(() => add(2, null)).toThrow();
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division par zéro interdite');
});
});
});
describe('ShoppingCart', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
});
test('should start with empty cart', () => {
expect(cart.items).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
});
test('should add item to cart', () => {
const item = { id: 1, name: 'Laptop', price: 999 };
cart.addItem(item);
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toMatchObject(item);
expect(cart.items[0].quantity).toBe(1);
});
test('should increase quantity for existing item', () => {
const item = { id: 1, name: 'Laptop', price: 999 };
cart.addItem(item, 2);
cart.addItem(item, 1);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
});
test('should calculate total correctly', () => {
const laptop = { id: 1, name: 'Laptop', price: 999 };
const mouse = { id: 2, name: 'Mouse', price: 25 };
cart.addItem(laptop, 1);
cart.addItem(mouse, 2);
expect(cart.getTotal()).toBe(1049); // 999 + (25 * 2)
});
});
Tests Python avec Pytest
Installation :
pip install pytest pytest-cov
Fonction à tester :
# utils/validator.py
import re
from typing import Optional
class EmailValidator:
def __init__(self):
self.email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
def is_valid(self, email: str) -> bool:
if not email or not isinstance(email, str):
return False
return bool(re.match(self.email_pattern, email))
def extract_domain(self, email: str) -> Optional[str]:
if not self.is_valid(email):
return None
return email.split('@')[1]
def calculate_age(birth_year: int, current_year: int = 2024) -> int:
if birth_year > current_year:
raise ValueError("L'année de naissance ne peut pas être dans le futur")
if current_year - birth_year > 150:
raise ValueError("âge trop élevé (> 150 ans)")
return current_year - birth_year
Tests correspondants :
# tests/test_validator.py
import pytest
from utils.validator import EmailValidator, calculate_age
class TestEmailValidator:
def setup_method(self):
self.validator = EmailValidator()
def test_valid_emails(self):
valid_emails = [
"[email protected]",
"[email protected]",
"[email protected]"
]
for email in valid_emails:
assert self.validator.is_valid(email), f"Email {email} should be valid"
def test_invalid_emails(self):
invalid_emails = [
"",
"not-an-email",
"@example.com",
"test@",
"test.example.com"
]
for email in invalid_emails:
assert not self.validator.is_valid(email), f"Email {email} should be invalid"
def test_non_string_input(self):
assert not self.validator.is_valid(None)
assert not self.validator.is_valid(123)
assert not self.validator.is_valid([])
def test_extract_domain(self):
assert self.validator.extract_domain("[email protected]") == "example.com"
assert self.validator.extract_domain("invalid-email") is None
class TestCalculateAge:
def test_normal_age_calculation(self):
assert calculate_age(1990, 2024) == 34
assert calculate_age(2000, 2024) == 24
def test_birth_year_in_future_raises_error(self):
with pytest.raises(ValueError, match="ne peut pas être dans le futur"):
calculate_age(2025, 2024)
def test_age_too_high_raises_error(self):
with pytest.raises(ValueError, match="âge trop élevé"):
calculate_age(1800, 2024)
@pytest.mark.parametrize("birth_year,expected_age", [
(1990, 34),
(2000, 24),
(2020, 4),
(2024, 0)
])
def test_age_calculation_with_parameters(self, birth_year, expected_age):
assert calculate_age(birth_year, 2024) == expected_age
Techniques avancées
Mocks et stubs
JavaScript avec Jest :
// service/api.js
export async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
// __tests__/api.test.js
import { fetchUserData } from '../service/api.js';
// Mock global fetch
global.fetch = jest.fn();
describe('fetchUserData', () => {
beforeEach(() => {
fetch.mockClear();
});
test('should fetch user data successfully', async () => {
const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
const result = await fetchUserData(1);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(result).toEqual(mockUser);
});
});
Python avec pytest :
# tests/test_api.py
import pytest
from unittest.mock import Mock, patch
from service.api import UserService
class TestUserService:
@patch('service.api.requests.get')
def test_get_user_success(self, mock_get):
# Arrange
mock_response = Mock()
mock_response.json.return_value = {'id': 1, 'name': 'John'}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
service = UserService()
# Act
result = service.get_user(1)
# Assert
assert result['name'] == 'John'
mock_get.assert_called_once_with('/api/users/1')
Tests paramètres
@pytest.mark.parametrize("input,expected", [
("", False),
("[email protected]", True),
("invalid", False),
("[email protected]", True),
])
def test_email_validation(input, expected):
validator = EmailValidator()
assert validator.is_valid(input) == expected
TDD : Test-Driven Development
Cycle Red-Green-Refactor :
- ** Red :** écrire un test qui échoue
- ** Green :** écrire le code minimal pour faire passer le test
- ** Refactor :** Améliorer le code sans casser les tests
Exemple pratique :
// 1. Red - Test qui échoue
test('should format currency', () => {
expect(formatCurrency(1234.56)).toBe('1 234,56 €');
});
// 2. Green - Code minimal
function formatCurrency(amount) {
return amount.toLocaleString('fr-FR', {
style: 'currency',
currency: 'EUR'
});
}
// 3. Refactor - Améliorer si nécessaire
function formatCurrency(amount, currency = 'EUR', locale = 'fr-FR') {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new Error('Amount must be a valid number');
}
return amount.toLocaleString(locale, {
style: 'currency',
currency
});
}
Bonnes pratiques
Nommage des tests
// Mauvais
test('test user', () => {});
// Bon
test('should return user data when valid ID is provided', () => {});
Un seul assert par test
// Mauvais
test('user operations', () => {
const user = createUser('John');
expect(user.name).toBe('John');
expect(user.isActive).toBe(true);
expect(user.role).toBe('user');
});
// Bon
describe('User creation', () => {
test('should set correct name', () => {
const user = createUser('John');
expect(user.name).toBe('John');
});
test('should be active by default', () => {
const user = createUser('John');
expect(user.isActive).toBe(true);
});
});
Coverage vs qualité
# Générer un rapport de couverture
npm run test -- --coverage
# Objectif : 80-90% de couverture, mais priorisez la qualité
FAQ
Faut-il tester les fonctions privées ?
Non. Testez seulement l’API publique. Si une fonction privée pose problème, elle sera détectée via les tests publics.
Combien de tests écrire ?
Suivez la pyramide des tests :
- 70% Tests unitaires
- 20% Tests d’intégration
- 10% Tests E2E
Comment gérer les tests lents ?
// Séparez les tests lents
describe('Integration tests', () => {
test.skip('slow database test', () => {
// Test qui prend du temps
});
});
// Ou utilisez des tags
npm test -- --testNamePattern="^(?!.*integration)"
Dois-je faire du TDD systématiquement ?
Pas nécessairement. Utilisez TDD pour :
- Algorithmes complexes
- Fonctions critiques
- Bugs difficiles à reproduire
Comment tester du code legacy ?
- Commencez par les nouvelles features
- Ajoutez des tests lors des corrections de bugs
- Utilisez des characterization tests pour documenter le comportement existant
Les tests unitaires demandent un investissement initial, mais ils rendent le développement plus rapide et serein. Commencez petit, et construisez progressivement une suite de tests robuste !