Tests fondamentaux - 05 - Tests fonctionnels : valider les use cases complets

Tester un use case de bout en bout avec des fakes. La différence entre test unitaire et test fonctionnel en archi hexagonale.

05 - Tests fonctionnels : valider les use cases complets

Ce que tu vas apprendre

  • La différence entre test unitaire et test fonctionnel
  • Comment tester un use case complet sans infrastructure
  • Pourquoi les fakes battent les mocks pour ce type de test

Prerequisites

Avoir lu l'article 03 - Mocks et idealement la serie sur l'architecture hexagonale.


Le problème du test unitaire seul

Un test unitaire teste une fonction isolee. C'est bien. Mais quand ton use case fait 5 étapes (valider les donnees, créer l'entité, la persister, envoyer un email, retourner le résultat), tester chaque étape séparément ne te dit pas si le flow complet fonctionne.

J'ai eu le cas sur paltemps.fr : chaque fonction passait ses tests individuels, mais le use case complet plantait parce qu'une étape retournait un format inattendu par la suivante. Les tests unitaires ne voyaient rien, le bug passait en prod.

Le test fonctionnel resout ca. Il teste le use case entier avec de la vraie logique métier, mais en remplacant l'infrastructure par des fakes.

Test unitaire vs test fonctionnel

Test unitaire Test fonctionnel
Scope Une fonction Un use case complet
Dépendances Aucune (ou mockees) Fakes pour l'infra
Logique métier Testee en isolation Testee dans son contexte
Vitesse Millisecondes Millisecondes (pas de DB)
Confiance Moyenne Haute

Le test fonctionnel est rapide comme un unitaire (pas d'I/O réelle) mais couvre autant qu'un test d'intégration. C'est le meilleur des deux mondes.

Les fakes : le secret du test fonctionnel

En architecture hexagonale, tes ports definissent des interfaces. Les adaptateurs les implementent. Pour les tests fonctionnels, tu créés des adaptateurs "fake" : des implementations qui marchent, mais en mémoire.

typescript// src/domain/ports.ts
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findAll(): Promise<Order[]>;
}

export interface EmailSender {
  send(to: string, subject: string, body: string): Promise<void>;
}
typescript// src/test/fakes/in-memory-order-repository.ts
export class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map();

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

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

  async findAll(): Promise<Order[]> {
    return [...this.orders.values()];
  }
}
typescript// src/test/fakes/fake-email-sender.ts
export class FakeEmailSender implements EmailSender {
  sentEmails: { to: string; subject: string; body: string }[] = [];

  async send(to: string, subject: string, body: string): Promise<void> {
    this.sentEmails.push({ to, subject, body });
  }
}

Le InMemoryOrderRepository stocke les donnees dans une Map. Pas de PostgreSQL, pas de Docker, pas de nettoyage. Et le FakeEmailSender garde une trace des emails envoyes au lieu de les envoyer pour de vrai.

Le test fonctionnel complet

Voici un use case : créer une commande, la persister, envoyer un email de confirmation.

typescript// src/domain/create-order.ts
export class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private emailSender: EmailSender
  ) {}

  async execute(input: {
    items: { product: string; qty: number; price: number }[];
    customerEmail: string;
  }): Promise<Order> {
    if (input.items.length === 0) {
      throw new Error("Commande vide");
    }

    const order = Order.create(input);
    await this.orderRepo.save(order);

    await this.emailSender.send(
      input.customerEmail,
      `Commande ${order.id} confirmee`,
      `Votre commande de ${order.total} EUR est confirmee.`
    );

    return order;
  }
}

Et le test :

typescript// src/domain/create-order.test.ts
import { describe, it, expect } from "bun:test";
import { CreateOrderUseCase } from "./create-order";
import { InMemoryOrderRepository } from "../test/fakes/in-memory-order-repository";
import { FakeEmailSender } from "../test/fakes/fake-email-sender";

describe("CreateOrderUseCase", () => {
  function setup() {
    const orderRepo = new InMemoryOrderRepository();
    const emailSender = new FakeEmailSender();
    const useCase = new CreateOrderUseCase(orderRepo, emailSender);
    return { orderRepo, emailSender, useCase };
  }

  it("creates order, persists it, and sends confirmation email", async () => {
    const { orderRepo, emailSender, useCase } = setup();

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

    // L'ordre a le bon total
    expect(order.status).toBe("CONFIRMED");
    expect(order.total).toBe(35);

    // L'ordre est persiste
    const persisted = await orderRepo.findById(order.id);
    expect(persisted).not.toBeNull();
    expect(persisted!.total).toBe(35);

    // L'email est envoye
    expect(emailSender.sentEmails).toHaveLength(1);
    expect(emailSender.sentEmails[0].to).toBe("alice@example.com");
    expect(emailSender.sentEmails[0].subject).toContain("confirmee");
  });

  it("rejects empty order", async () => {
    const { useCase } = setup();

    expect(
      useCase.execute({ items: [], customerEmail: "alice@example.com" })
    ).rejects.toThrow("Commande vide");
  });

  it("calculates total with multiple items", async () => {
    const { useCase } = setup();

    const order = await useCase.execute({
      items: [
        { product: "A", qty: 3, price: 10 },
        { product: "B", qty: 2, price: 25 },
      ],
      customerEmail: "bob@example.com",
    });

    expect(order.total).toBe(80);
  });
});

Regarde ce qu'on vérifié dans un seul test :

  1. La validation (pas de commande vide)
  2. Le calcul du total (logique métier)
  3. La persistence (le fake repo contient la commande)
  4. L'envoi d'email (le fake sender a reçu un email)
  5. Le status de la commande

Tout ca en millisecondes, sans Docker, sans PostgreSQL, sans serveur SMTP.

Le pattern setup()

Tu as remarque la fonction setup() en haut du describe. C'est une alternative a beforeEach que je préféré pour les tests fonctionnels. Chaque test appelle setup() et destructure ce dont il a besoin.

Avantages :

  • Chaque test est explicite sur ses dépendances
  • Pas d'état partage entre les tests
  • Tu peux customiser le setup par test si besoin
typescriptit("handles order with discount code", async () => {
  const { orderRepo, emailSender } = setup();
  // On injecte un service de discount specifique
  const discountService = new FixedDiscountService(10);
  const useCase = new CreateOrderUseCase(orderRepo, emailSender, discountService);

  const order = await useCase.execute({
    items: [{ product: "Widget", qty: 1, price: 100 }],
    customerEmail: "alice@example.com",
    discountCode: "WELCOME10",
  });

  expect(order.total).toBe(90);
});

Quand utiliser des tests fonctionnels

Je les utilise pour :

  • Chaque use case métier : CreateOrder, CancelBooking, RegisterUser, etc.
  • Les workflows a plusieurs étapes : le test vérifié que les étapes s'enchainent correctement
  • Les regles business complexes : conditions, calculs, validations qui dependent de l'état

Je ne les utilise PAS pour :

  • Les requêtes SQL : la, il faut un test d'intégration avec une vraie DB (voir article 04)
  • Le rendering HTML : pour ca, les snapshots sont mieux (voir article 08)
  • Les interactions navigateur : c'est le boulot des tests e2e (voir article 06)

Fakes vs Mocks : le match final

On en a parle dans l'article 03, mais je veux enfoncer le clou.

Avec des mocks, tu verifies des appels :

typescript// "Est-ce que save() a ete appele ?"
expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ total: 35 }));

Avec des fakes, tu verifies des résultats :

typescript// "Est-ce que la commande est vraiment la ?"
const persisted = await orderRepo.findById(order.id);
expect(persisted!.total).toBe(35);

La deuxieme version est plus solide. Si tu refactores l'intérieur du use case (par exemple en ajoutant un validate() avant le save()), les tests avec fakes continuent de passer. Les tests avec mocks cassent parce que l'ordre des appels a change.

Les fakes testent le comportement. Les mocks testent l'implementation. Le comportement, c'est ce qui compte.


Article précédent : 04 - Tests d'intégration Article suivant : 06 - Tests e2e avec Playwright

Sources

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