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 :
- La validation (pas de commande vide)
- Le calcul du total (logique métier)
- La persistence (le fake repo contient la commande)
- L'envoi d'email (le fake sender a reçu un email)
- 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