05 - Tester sans souffrir : le vrai gain de l'hexa
Ce que tu vas apprendre
- Pourquoi l'architecture hexagonale simplifie radicalement les tests
- Comment tester le domaine sans aucune infrastructure
- Comment tester les use cases avec des adaptateurs in-memory
- Quand utiliser de vrais tests d'intégration
Prerequisites
Le vrai argument pour l'architecture hexagonale
La séparation des responsabilités, l'inversion de dépendance, la permutabilite des adaptateurs... tout ca c'est bien sur le papier. Mais le gain que tu ressens au quotidien, celui qui change réellement ta vie de développeur fullstack, c'est la testabilité.
Quand tes tests unitaires tournent en 200ms sans Docker, sans base de donnees, sans mock complexe, tu testes plus souvent. Tu testes mieux. Tu as confiance dans ton code. Et ca, ca n'a pas de prix.
Niveau 1 : tester le domaine (zero dépendance)
Le domaine n'a aucune dépendance externe. Donc ses tests n'en ont aucune non plus. Pas de mock, pas de stub, pas de spy. Du TypeScript pur.
typescript// domain/__tests__/Order.test.ts
import { describe, it, expect } from "vitest";
import { Order } from "../entities/Order";
describe("Order", () => {
function createDraftOrder(): Order {
return new Order("order-1", "customer-1", [], "draft", new Date());
}
it("calcule le prix total des articles", () => {
const order = createDraftOrder();
order.addItem({
productId: "p1",
name: "T-shirt",
quantity: 2,
unitPrice: 19.99,
});
order.addItem({
productId: "p2",
name: "Casquette",
quantity: 1,
unitPrice: 14.5,
});
expect(order.totalPrice()).toBe(54.48);
});
it("refuse de confirmer une commande vide", () => {
const order = createDraftOrder();
expect(() => order.confirm()).toThrow(
"Impossible de confirmer une commande vide"
);
});
it("refuse d'annuler une commande expediee", () => {
const order = new Order(
"order-1",
"customer-1",
[{ productId: "p1", name: "T-shirt", quantity: 1, unitPrice: 19.99 }],
"shipped",
new Date()
);
expect(() => order.cancel()).toThrow(
"Impossible d'annuler une commande deja expediee"
);
});
it("suit les transitions d'etat correctement", () => {
const order = createDraftOrder();
order.addItem({
productId: "p1",
name: "T-shirt",
quantity: 1,
unitPrice: 19.99,
});
order.confirm();
expect(order.getStatus()).toBe("confirmed");
order.markAsPaid();
expect(order.getStatus()).toBe("paid");
});
it("refuse d'ajouter un article a une commande confirmee", () => {
const order = createDraftOrder();
order.addItem({
productId: "p1",
name: "T-shirt",
quantity: 1,
unitPrice: 19.99,
});
order.confirm();
expect(() =>
order.addItem({
productId: "p2",
name: "Casquette",
quantity: 1,
unitPrice: 14.5,
})
).toThrow("Ajout d'articles possible uniquement en brouillon");
});
});
Ces tests tournent en quelques millisecondes. Aucun setup, aucun teardown, aucun container Docker. Tu les lances 50 fois par jour sans y penser.
Si tu as lu la serie Domaines et cycles de vie, tu reconnais le pattern : on teste les invariants et les transitions d'état du domaine.
Niveau 2 : tester les use cases (adaptateurs in-memory)
Les use cases (application services) orchestrent le domaine et utilisent les ports sortants. Pour les tester, on injecte des adaptateurs in-memory a la place des vrais adaptateurs d'infrastructure.
typescript// app/__tests__/CreateOrderUseCase.test.ts
import { describe, it, expect } from "vitest";
import { CreateOrderUseCase } from "../CreateOrderUseCase";
import { InMemoryOrderRepository } from "../../adapters/outbound/InMemoryOrderRepository";
describe("CreateOrderUseCase", () => {
it("cree une commande et la sauvegarde", async () => {
const repository = new InMemoryOrderRepository();
const useCase = new CreateOrderUseCase(repository);
const result = await useCase.execute({
customerId: "customer-42",
items: [
{ productId: "p1", name: "Clavier", quantity: 1, unitPrice: 89.99 },
{ productId: "p2", name: "Souris", quantity: 2, unitPrice: 34.99 },
],
});
expect(result.totalPrice).toBe(159.97);
expect(result.status).toBe("draft");
const saved = await repository.findById(result.orderId);
expect(saved).not.toBeNull();
expect(saved!.totalPrice()).toBe(159.97);
});
it("genere un ID unique pour chaque commande", async () => {
const repository = new InMemoryOrderRepository();
const useCase = new CreateOrderUseCase(repository);
const command = {
customerId: "customer-42",
items: [
{ productId: "p1", name: "Clavier", quantity: 1, unitPrice: 89.99 },
],
};
const result1 = await useCase.execute(command);
const result2 = await useCase.execute(command);
expect(result1.orderId).not.toBe(result2.orderId);
});
});
Pas de mock. On utilise un vrai objet InMemoryOrderRepository qui implemente la meme interface que PostgresOrderRepository. Le use case ne sait pas qu'il parle a de la mémoire plutot qu'a PostgreSQL. Et c'est exactement le but.
Compare ca avec l'approche classique Express + Prisma :
typescript// L'enfer des mocks (approche classique)
import { describe, it, expect, vi } from "vitest";
import { createOrder } from "../controllers/orderController";
describe("createOrder (classique)", () => {
it("cree une commande", async () => {
// Mock Prisma
const mockPrisma = {
order: {
create: vi.fn().mockResolvedValue({
id: "order-1",
customerId: "customer-42",
items: [],
status: "draft",
totalPrice: 159.97,
}),
},
};
// Mock Request
const mockReq = {
body: {
customerId: "customer-42",
items: [{ productId: "p1", name: "Clavier", quantity: 1, unitPrice: 89.99 }],
},
};
// Mock Response
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
// Injecter le mock Prisma... comment ? Variable globale ? Module mock ?
// Et on n'a meme pas teste la logique metier, juste que Prisma est appele.
});
});
Avec les mocks, tu testes que Prisma est appele avec les bons arguments. Tu ne testes pas que la logique métier fonctionne. C'est une différence énorme.
Niveau 3 : tests d'intégration (vrais adaptateurs)
Les adaptateurs eux-memes meritent des tests d'intégration. C'est le seul endroit ou tu as besoin d'infrastructure réelle.
typescript// adapters/outbound/__tests__/PostgresOrderRepository.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Pool } from "pg";
import { PostgresOrderRepository } from "../PostgresOrderRepository";
import { Order } from "../../../domain/entities/Order";
describe("PostgresOrderRepository", () => {
let pool: Pool;
let repository: PostgresOrderRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new PostgresOrderRepository(pool);
await pool.query("DELETE FROM orders");
});
afterAll(async () => {
await pool.end();
});
it("sauvegarde et retrouve une commande", async () => {
const order = new Order(
"integration-test-1",
"customer-1",
[{ productId: "p1", name: "Widget", quantity: 3, unitPrice: 9.99 }],
"draft",
new Date()
);
await repository.save(order);
const found = await repository.findById("integration-test-1");
expect(found).not.toBeNull();
expect(found!.totalPrice()).toBe(29.97);
expect(found!.getStatus()).toBe("draft");
});
});
Ces tests-la sont lents, ils demandent une base de donnees. Mais ils ne testent qu'une chose : est-ce que l'adaptateur traduit correctement entre le domaine et PostgreSQL ? C'est un perimetre réduit.
Mon opinion sur Docker dans les tests
Si tes tests unitaires ont besoin de Docker pour tourner, ton architecture a un problème. C'est un signal que ta logique métier est couplee a l'infrastructure.
Avec l'architecture hexagonale, voici la repartition que je recommande :
- 90% de tests unitaires : domaine + use cases avec adaptateurs in-memory. Rapides, fiables, sans infrastructure
- 10% de tests d'intégration : adaptateurs avec la vraie infra. Lents mais nécessaires pour vérifier le cablage
Cette repartition te donne un feedback loop de quelques secondes sur la logique métier, et une vérification complète en quelques minutes sur le CI.
Sur paltemps.fr, on applique cette repartition sur tous les projets backend avec des bonnes pratiques d'architecture hexagonale. Le temps de CI a ete divise par 3 sur certains projets.
Résumé
- Le domaine se teste sans aucune dépendance : pas de mock, pas de Docker
- Les use cases se testent avec des adaptateurs in-memory (vrais objets, pas des mocks)
- Les adaptateurs se testent en intégration avec la vraie infrastructure
- Si tes tests unitaires ont besoin de Docker, c'est un signal d'alarme
- Vise 90% de tests rapides (domaine + use cases), 10% de tests d'intégration
Article précédent : 04 - Les adaptateurs
Article suivant : 06 - Projet complet : une API de commandes en TypeScript