Pierry Lim

Écrire des tests unitaires efficacement

· Mis à jour le 17 janvier 2026 · 9 min de lecture
Tests
Qualité
JavaScript
Python
TDD

Guide pratique pour écrire des tests unitaires efficaces : bonnes pratiques, frameworks modernes (Jest, Pytest), mocks et TDD. Approche pragmatique avec exemples concrets.

Écrire des tests unitaires efficacement

Sommaire

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 :

  1. ** Red :** écrire un test qui échoue
  2. ** Green :** écrire le code minimal pour faire passer le test
  3. ** 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 ?

  1. Commencez par les nouvelles features
  2. Ajoutez des tests lors des corrections de bugs
  3. 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 !

Travaillons ensemble

Vous cherchez un développeur freelance pour créer un site web ou une application sur mesure ? Contactez-moi pour discuter de votre projet et obtenir un devis personnalisé.