Tests fondamentaux - 03 - Mocks, stubs, fakes et spies : le guide de survie

La différence entre mock, stub, fake et spy. Quand les utiliser avec bun test, et quand s'en passer.

03 - Mocks, stubs, fakes et spies : le guide de survie

Ce que tu vas apprendre

  • La différence entre spy, stub, mock et fake
  • Comment utiliser mock() et spyOn() de Bun
  • Quand mocker et quand s'en passer (opinion forte incluse)

Prerequisites

Avoir lu l'article 02 - Tests unitaires.


Le problème

Ta fonction createOrder envoie un email de confirmation. Tu ne veux pas envoyer un vrai email a chaque bun test. Ta fonction fetchWeather appelle une API externe. Tu ne veux pas dépendre de la meteo pour que tes tests passent.

Il te faut des doublures de test (test doubles). C'est le terme générique pour "trucs qui remplacent les vrais composants dans les tests". Il y en a quatre types, et les confondre mene a du code de test inmaintenable.

Les quatre types de doublures

Spy : observer sans modifier

Un spy enveloppe une vraie fonction. La fonction s'exécuté normalement, mais tu peux vérifier qu'elle a ete appelee, avec quels arguments, combien de fois.

typescriptimport { describe, it, expect, spyOn } from "bun:test";

describe("Logger", () => {
  it("logs the message", () => {
    const spy = spyOn(console, "log");

    processOrder({ id: 1, total: 42 });

    expect(spy).toHaveBeenCalledWith("Order processed:", 1);
    spy.mockRestore(); // restaure console.log original
  });
});

Utilise un spy quand : tu veux vérifier qu'une fonction existante est appelee sans changer son comportement.

Stub : remplacer par une valeur fixe

Un stub remplace une fonction par une implementation qui retourne toujours la meme chose. La vraie implementation ne s'exécuté jamais.

typescriptimport { describe, it, expect, spyOn } from "bun:test";
import * as weatherApi from "./weather-api";

describe("getRecommendation", () => {
  it("recommends indoor activity when raining", () => {
    const stub = spyOn(weatherApi, "fetchWeather").mockReturnValue(
      Promise.resolve({ condition: "rain", temp: 12 })
    );

    const result = await getRecommendation("Paris");

    expect(result).toBe("Visite un musee");
    stub.mockRestore();
  });
});

Utilise un stub quand : tu veux contrôler la valeur de retour d'une dépendance (API externe, système de fichiers, horloge).

Mock : vérifier les interactions

Un mock est une fonction créée de toutes pieces avec mock(). Elle n'a pas de "vraie" implementation. Tu la passes en dépendance et tu verifies qu'elle a ete appelee correctement.

typescriptimport { describe, it, expect, mock } from "bun:test";

describe("CreateOrderUseCase", () => {
  it("sends confirmation email after creating order", async () => {
    const sendEmail = mock(() => Promise.resolve());

    const useCase = new CreateOrderUseCase({
      orderRepo: new InMemoryOrderRepository(),
      sendEmail,
    });

    await useCase.execute({
      items: [{ product: "Widget", qty: 2, price: 10 }],
      customerEmail: "alice@example.com",
    });

    expect(sendEmail).toHaveBeenCalledTimes(1);
    expect(sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "alice@example.com",
        subject: expect.stringContaining("Confirmation"),
      })
    );
  });
});

Utilise un mock quand : tu veux vérifier qu'une dépendance est appelee avec les bons arguments, sans te soucier de son implementation.

Fake : une vraie implementation simplifiee

Un fake est un objet qui implemente la meme interface que le vrai, mais avec une implementation simplifiee. Le cas classique : un repository en mémoire au lieu de PostgreSQL.

typescript// src/infra/in-memory-order-repository.ts
export class InMemoryOrderRepository implements OrderRepository {
  private orders: Order[] = [];

  async save(order: Order): Promise<void> {
    this.orders.push(order);
  }

  async findById(id: string): Promise<Order | null> {
    return this.orders.find((o) => o.id === id) ?? null;
  }

  async findAll(): Promise<Order[]> {
    return [...this.orders];
  }
}
typescriptdescribe("OrderService", () => {
  it("saves and retrieves order", async () => {
    const repo = new InMemoryOrderRepository();
    const service = new OrderService(repo);

    const order = await service.create([{ product: "Widget", qty: 1, price: 10 }]);
    const found = await repo.findById(order.id);

    expect(found).not.toBeNull();
    expect(found!.total).toBe(10);
  });
});

Utilise un fake quand : tu veux tester un flow complet sans infrastructure réelle. Les fakes sont ma doublure préférée pour les tests fonctionnels (voir article 05).

L'API de Bun en détail

`mock(fn)`

Cree une fonction mockee :

typescriptimport { mock } from "bun:test";

const fn = mock(() => 42);
fn();
fn("hello");

expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith("hello");
expect(fn.mock.calls).toEqual([[], ["hello"]]);
expect(fn.mock.results).toEqual([
  { type: "return", value: 42 },
  { type: "return", value: 42 },
]);

`spyOn(object, method)`

Espionne une méthode existante :

typescriptimport { spyOn } from "bun:test";

const spy = spyOn(console, "error");
// ... code qui appelle console.error ...
expect(spy).toHaveBeenCalled();
spy.mockRestore();

Tu peux aussi remplacer l'implementation :

typescriptconst spy = spyOn(Date, "now").mockReturnValue(1700000000000);
// Date.now() retourne toujours 1700000000000
spy.mockRestore();

Matchers de vérification

typescriptexpect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenCalledWith("arg1", "arg2");
expect(fn).toHaveBeenLastCalledWith("last arg");
expect(fn).toHaveBeenNthCalledWith(2, "second call arg");

L'anti-pattern : tout mocker

J'ai vu des tests ou chaque dépendance est mockee. Le repository ? Mock. Le logger ? Mock. Le validateur ? Mock. Le service appele ? Mock. Il reste quoi a tester ? Rien. Le test vérifié que tu appelles des fonctions dans le bon ordre, pas que le code marche.

Voici mon heuristique :

Si tu mockes plus de 2 dépendances, ta fonction fait trop de choses. Refactore-la. Extrais la logique pure dans des fonctions sans dépendances, teste-les unitairement, et reserve les mocks pour les frontieres du système (API, DB, email, filesystem).

La hiérarchie de préférence pour les doublures :

  1. Pas de doublure : teste la vraie chose. Si ta fonction est pure, tu n'as besoin de rien.
  2. Fake : une implementation simplifiee. Le InMemoryRepository est ton meilleur ami. Ca teste la logique réelle sans l'infrastructure.
  3. Stub : pour contrôler l'environnement (horloge, API externe, config).
  4. Mock/Spy : en dernier recours, pour vérifier des effets de bord (email envoye, log écrit).

Exemple concret : tester un service métier

Imaginons un service qui créé une reservation sur paltemps.fr :

typescript// src/domain/booking-service.ts
export class BookingService {
  constructor(
    private bookingRepo: BookingRepository,
    private notifier: Notifier
  ) {}

  async book(date: Date, userId: string): Promise<Booking> {
    if (date < new Date()) {
      throw new Error("Impossible de reserver dans le passe");
    }
    const booking = Booking.create(date, userId);
    await this.bookingRepo.save(booking);
    await this.notifier.send(userId, `Reservation confirmee pour le ${date.toLocaleDateString()}`);
    return booking;
  }
}

Le test :

typescriptimport { describe, it, expect, mock } from "bun:test";

describe("BookingService", () => {
  it("creates booking and notifies user", async () => {
    const repo = new InMemoryBookingRepository(); // Fake
    const notifier = { send: mock(() => Promise.resolve()) }; // Mock

    const service = new BookingService(repo, notifier);
    const futureDate = new Date("2027-06-15");

    const booking = await service.book(futureDate, "user-123");

    expect(booking.userId).toBe("user-123");
    expect(await repo.findById(booking.id)).not.toBeNull();
    expect(notifier.send).toHaveBeenCalledWith(
      "user-123",
      expect.stringContaining("Reservation confirmee")
    );
  });

  it("rejects past dates", async () => {
    const repo = new InMemoryBookingRepository();
    const notifier = { send: mock(() => Promise.resolve()) };
    const service = new BookingService(repo, notifier);

    expect(service.book(new Date("2020-01-01"), "user-123")).rejects.toThrow("dans le passe");
  });
});

Deux doublures, pas plus. Le repo est un fake (on vérifié qu'il persiste). Le notifier est un mock (on vérifié qu'il est appele). La logique métier (validation de date) est testee directement.


Article précédent : 02 - Tests unitaires avec bun test Article suivant : 04 - Tests d'intégration

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.