Tests Unitaires

Tests Unitaires JavaScript avec Jest : Guide Pratique

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Tests Unitaires JavaScript avec Jest : Guide Pratique

Pourquoi Jest s'est imposé comme standard

Quand on parle de tests unitaires en JavaScript, Jest revient dans presque toutes les discussions. Ce framework créé par Facebook (maintenant Meta) s'est imposé comme la référence du secteur, et pour de bonnes raisons : il est livré batteries incluses, sa configuration est minimale, et il fonctionne aussi bien pour du JavaScript vanilla que pour React, Vue, Node.js ou TypeScript.

Dans cet article, on va aller au-delà du simple npm install jest. On va construire une suite de tests robuste, comprendre les mécanismes sous-jacents, et adopter les pratiques qui font la différence entre des tests qui ralentissent l'équipe et des tests qui la protègent.

Installation et configuration

Commençons par une installation propre :

npm install --save-dev jest
# Pour TypeScript
npm install --save-dev jest @types/jest ts-jest
# Pour ESM
npm install --save-dev jest babel-jest @babel/core @babel/preset-env

La configuration minimale dans package.json :

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "collectCoverageFrom": ["src/**/*.{js,ts}", "!src/**/*.d.ts"]
  }
}

Pour TypeScript, créez un fichier jest.config.ts :

import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;

Anatomie d'un bon test

Avant d'écrire une seule ligne de test, il faut comprendre la structure AAA : Arrange, Act, Assert. C'est le squelette de tout bon test unitaire.

// src/calculator.ts
export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Division par zéro impossible');
  }
  return a / b;
}

// src/__tests__/calculator.test.ts
import { divide } from '../calculator';

describe('divide', () => {
  it('divise deux nombres positifs correctement', () => {
    // Arrange
    const a = 10;
    const b = 2;

    // Act
    const result = divide(a, b);

    // Assert
    expect(result).toBe(5);
  });

  it('gère la division par zéro en lançant une erreur', () => {
    // Arrange & Act & Assert (cas simple)
    expect(() => divide(10, 0)).toThrow('Division par zéro impossible');
  });

  it('retourne un nombre décimal pour une division non entière', () => {
    expect(divide(1, 3)).toBeCloseTo(0.333, 3);
  });
});

Notez l'utilisation de toBeCloseTo pour les nombres flottants — c'est un classique que beaucoup oublient et qui génère des tests fragiles.

Les matchers essentiels

Jest propose une palette de matchers très complète. Voici ceux que vous utiliserez au quotidien :

// Égalité
expect(value).toBe(42);          // Identité stricte (===)
expect(obj).toEqual({ a: 1 });   // Égalité profonde (deep equal)
expect(obj).toStrictEqual({...}); // Comme toEqual + vérifie undefined

// Vérité/Fausseté
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Nombres
expect(n).toBeGreaterThan(10);
expect(n).toBeLessThanOrEqual(100);
expect(n).toBeCloseTo(3.14, 2);

// Chaînes
expect(str).toMatch(/regex/);
expect(str).toContain('sous-chaîne');

// Tableaux
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));

// Objets
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ a: 1 }); // Vérifie un sous-ensemble

// Erreurs
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(TypeError);
expect(() => fn()).toThrow('message spécifique');

// Négation
expect(value).not.toBe(null);

Un pattern utile pour les assertions partielles sur des objets :

// Vous voulez vérifier certains champs sans vous soucier des autres
const user = await createUser({ name: 'Alice', email: '[email protected]' });

expect(user).toMatchObject({
  name: 'Alice',
  email: '[email protected]',
  // On ne vérifie pas id, createdAt, updatedAt...
});
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);

Mocks et espions

C'est là que Jest brille vraiment. Les mocks permettent d'isoler le code testé de ses dépendances externes.

jest.fn() — la fonction espion

// src/services/email.ts
export interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

// src/services/user.ts
export class UserService {
  constructor(private emailService: EmailService) {}

  async register(email: string, name: string): Promise<{ id: string; email: string }> {
    const user = { id: crypto.randomUUID(), email, name };
    await this.emailService.send(
      email,
      'Bienvenue !',
      `Bonjour ${name}, votre compte est créé.`
    );
    return user;
  }
}

// src/__tests__/user.service.test.ts
import { UserService } from '../services/user';

describe('UserService', () => {
  describe('register', () => {
    it('crée un utilisateur et envoie un email de bienvenue', async () => {
      // Arrange
      const mockEmailService = {
        send: jest.fn().mockResolvedValue(undefined),
      };
      const userService = new UserService(mockEmailService);

      // Act
      const user = await userService.register('[email protected]', 'Alice');

      // Assert — le résultat
      expect(user).toMatchObject({
        email: '[email protected]',
      });
      expect(user.id).toBeDefined();

      // Assert — les effets de bord
      expect(mockEmailService.send).toHaveBeenCalledTimes(1);
      expect(mockEmailService.send).toHaveBeenCalledWith(
        '[email protected]',
        'Bienvenue !',
        expect.stringContaining('Alice')
      );
    });

    it('propage les erreurs d\'envoi d\'email', async () => {
      const mockEmailService = {
        send: jest.fn().mockRejectedValue(new Error('SMTP timeout')),
      };
      const userService = new UserService(mockEmailService);

      await expect(
        userService.register('[email protected]', 'Alice')
      ).rejects.toThrow('SMTP timeout');
    });
  });
});

jest.mock() — mocker des modules entiers

// src/repositories/user.repository.ts
import { db } from '../db/connection';

export async function findUserById(id: string) {
  return db.query('SELECT * FROM users WHERE id = $1', [id]);
}

// src/__tests__/user.repository.test.ts
jest.mock('../db/connection', () => ({
  db: {
    query: jest.fn(),
  },
}));

import { db } from '../db/connection';
import { findUserById } from '../repositories/user.repository';

const mockDb = db as jest.Mocked<typeof db>;

describe('findUserById', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('retourne l\'utilisateur quand il existe', async () => {
    const fakeUser = { id: '123', name: 'Bob', email: '[email protected]' };
    mockDb.query.mockResolvedValue({ rows: [fakeUser], rowCount: 1 });

    const result = await findUserById('123');

    expect(mockDb.query).toHaveBeenCalledWith(
      'SELECT * FROM users WHERE id = $1',
      ['123']
    );
    expect(result.rows[0]).toEqual(fakeUser);
  });
});

Mocker des timers

Jest permet de contrôler le temps, ce qui est indispensable pour tester les délais et les debounce :

// src/utils/debounce.ts
export function debounce<T extends (...args: unknown[]) => unknown>(
  fn: T,
  delay: number
): T {
  let timer: NodeJS.Timeout;
  return ((...args: unknown[]) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  }) as T;
}

// src/__tests__/debounce.test.ts
import { debounce } from '../utils/debounce';

describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('n\'appelle la fonction qu\'une fois après le délai', () => {
    const fn = jest.fn();
    const debouncedFn = debounce(fn, 300);

    debouncedFn();
    debouncedFn();
    debouncedFn();

    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledTimes(1);
  });

  it('remet le timer à zéro à chaque appel', () => {
    const fn = jest.fn();
    const debouncedFn = debounce(fn, 300);

    debouncedFn();
    jest.advanceTimersByTime(200);
    debouncedFn(); // Remet le timer à zéro
    jest.advanceTimersByTime(200);

    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(100);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

Organiser ses tests avec describe et les hooks

Les hooks de cycle de vie permettent de factoriser le code de setup/teardown :

describe('ProductService', () => {
  let service: ProductService;
  let mockRepository: jest.Mocked<ProductRepository>;

  // Exécuté une fois avant tous les tests du bloc
  beforeAll(() => {
    console.log('Suite de tests ProductService démarrée');
  });

  // Exécuté avant chaque test
  beforeEach(() => {
    mockRepository = {
      findById: jest.fn(),
      save: jest.fn(),
      delete: jest.fn(),
      findAll: jest.fn(),
    };
    service = new ProductService(mockRepository);
  });

  // Exécuté après chaque test
  afterEach(() => {
    jest.clearAllMocks();
  });

  // Exécuté une fois après tous les tests
  afterAll(() => {
    console.log('Suite de tests ProductService terminée');
  });

  describe('findById', () => {
    it('retourne le produit quand il existe', async () => {
      const product = { id: '1', name: 'Widget', price: 9.99 };
      mockRepository.findById.mockResolvedValue(product);

      const result = await service.findById('1');

      expect(result).toEqual(product);
      expect(mockRepository.findById).toHaveBeenCalledWith('1');
    });

    it('retourne null quand le produit n\'existe pas', async () => {
      mockRepository.findById.mockResolvedValue(null);

      const result = await service.findById('inexistant');

      expect(result).toBeNull();
    });
  });

  describe('save', () => {
    it('sauvegarde un produit valide', async () => {
      const product = { name: 'Nouveau Produit', price: 19.99 };
      const saved = { id: '2', ...product };
      mockRepository.save.mockResolvedValue(saved);

      const result = await service.save(product);

      expect(result).toEqual(saved);
    });
  });
});

Tests paramétrés avec test.each

Quand vous devez tester la même logique avec différentes entrées, test.each évite la duplication :

// src/utils/validation.ts
export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// src/__tests__/validation.test.ts
describe('isValidEmail', () => {
  test.each([
    ['[email protected]', true],
    ['[email protected]', true],
    ['[email protected]', true],
    ['invalid-email', false],
    ['@nodomain.com', false],
    ['noatsign.com', false],
    ['', false],
    ['spaces [email protected]', false],
  ])('isValidEmail("%s") retourne %s', (email, expected) => {
    expect(isValidEmail(email)).toBe(expected);
  });
});

// Version avec objets nommés (plus lisible pour les erreurs)
describe('calculateTVA', () => {
  test.each([
    { montant: 100, taux: 0.20, expected: 120, description: 'TVA 20%' },
    { montant: 100, taux: 0.10, expected: 110, description: 'TVA 10%' },
    { montant: 50, taux: 0.055, expected: 52.75, description: 'TVA 5.5%' },
  ])('$description : $montant€ → $expected€', ({ montant, taux, expected }) => {
    expect(calculateTVA(montant, taux)).toBeCloseTo(expected, 2);
  });
});

Couverture de code et seuils

La couverture de code est un indicateur, pas un objectif en soi. Mais configurer des seuils minimum empêche la régression :

// jest.config.ts
const config: Config = {
  coverageThreshold: {
    global: {
      branches: 75,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // Seuils par fichier pour les parties critiques
    './src/core/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
  coverageReporters: ['text', 'lcov', 'html'],
};

Lancez npm run test:coverage pour voir le rapport. L'indicateur le plus important est la couverture des branches — il mesure si vos if, switch, et opérateurs ternaires sont tous testés dans leurs deux cas.

Erreurs courantes à éviter

1. Tester l'implémentation plutôt que le comportement

// ❌ Test fragile — teste les détails d'implémentation
it('appelle formatDate puis concatenate', () => {
  const spy = jest.spyOn(utils, 'formatDate');
  formatUserName(user);
  expect(spy).toHaveBeenCalled(); // Qui s'en fout ?
});

// ✅ Test robuste — teste le comportement observable
it('formate le nom complet avec la date d\'inscription', () => {
  const result = formatUserName({ firstName: 'Alice', createdAt: new Date('2024-01-15') });
  expect(result).toBe('Alice — membre depuis janvier 2024');
});

2. Des tests qui dépendent les uns des autres

// ❌ L'ordre d'exécution peut causer des problèmes
let sharedState = [];

it('ajoute un élément', () => {
  sharedState.push('item');
  expect(sharedState).toHaveLength(1);
});

it('liste les éléments', () => {
  expect(sharedState).toContain('item'); // Dépend du test précédent !
});

// ✅ Chaque test est indépendant
beforeEach(() => {
  sharedState = [];
});

3. Négliger les cas limites

describe('splitIntoChunks', () => {
  it('divise un tableau en morceaux', () => {
    expect(splitIntoChunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
  });

  // Ces cas limites sont souvent oubliés
  it('gère un tableau vide', () => {
    expect(splitIntoChunks([], 2)).toEqual([]);
  });

  it('gère une taille de chunk supérieure au tableau', () => {
    expect(splitIntoChunks([1, 2], 10)).toEqual([[1, 2]]);
  });

  it('gère une taille de chunk de 1', () => {
    expect(splitIntoChunks([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
  });
});

Conclusion

Jest offre tout ce dont vous avez besoin pour écrire des tests unitaires JavaScript sérieux : configuration zéro, mocks puissants, assertions expressives, et couverture de code intégrée. La clé du succès n'est pas le framework — c'est la discipline de tester les comportements plutôt que les implémentations, de maintenir les tests indépendants, et de considérer les cas limites.

Commencez petit : ajoutez des tests aux nouvelles fonctions, puis progressivement aux parties critiques de votre code existant. Une suite de tests qui grandit organiquement avec votre codebase vaut mieux qu'un sprint "on ajoute des tests" qui ne dure pas.