DEV Community

Ulrich (Houngbe)
Ulrich (Houngbe)

Posted on

Tests unitaires : bonnes pratiques

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
Enter fullscreen mode Exit fullscreen mode

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');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)