ADR-06 — Stratégie de tests
Bun test partout, Playwright pour les 3 flows critiques, pas de TDD dogmatique
Statut : Accepté Date : 2026-04 Sujet : Quoi tester, avec quoi, quelle couverture
Contexte
L'ancien projet Bell n'avait aucun test automatisé. Chaque refacto cassait quelque chose en silence :
- Le flow check-in cassé à 4 reprises à cause de changements dans
linkGuestToUser - Des régressions Mews sync quand on touchait aux normalizeurs
- Des transitions de statut incohérentes (
arrivedpuis retour àpending) - L'AuthGuard client reprogrammé 6 fois parce que chaque fix cassait un autre cas
Sans tests, on teste à la main à chaque PR, et on rate toujours quelque chose. Avec l'IA qui modifie du code aussi, un filet de sécurité automatisé est non-négociable.
Principe — pragmatisme, pas dogmatisme
On ne fait pas de TDD strict partout. On ne vise pas 100 % de coverage (métrique mensongère). On teste ce qui est cher à casser :
- Transitions d'état métier (cf. ADR-05)
- Services avec logique métier (calculs de prix, matching, scoring)
- Routes API au niveau intégration (Elysia + vraie DB)
- Adapters PMS (contrat à respecter)
- 3 flows critiques en E2E
Les composants UI ne sont pas testés unitairement — on les valide visuellement via la route /design et en E2E sur les flows critiques.
La pyramide
| Niveau | Vitesse | Coverage cible | Quoi |
|---|---|---|---|
| Unitaires | < 50 ms / test | 90 %+ sur les machines, 80 %+ sur les services | State machines, helpers, calculs métier, validations |
| Intégration | 100–500 ms / test | 80 %+ sur les routes | Services avec vraie DB, routes Elysia via app.handle(), adapters avec API mockée |
| E2E | 5–30 s / test | 3 parcours imposés | Playwright |
Stack
| Outil | Rôle |
|---|---|
| Bun Test | Runner principal — natif Bun, 10× plus rapide que Jest |
| Postgres docker test | DB éphémère par worker Bun (isolation parfaite, voir plus bas) |
drizzle-seed | Seed de fixtures par test |
msw (futur) | Mock HTTP pour les tests adapter PMS |
| Playwright | E2E uniquement |
| Pas de Vitest, Jest, Mocha, ni Eden côté test | Bun test partout, appel direct app.handle(new Request(...)) pour tester les routes |
Conventions fichiers
Colocation stricte
packages/api/src/modules/cardex/
├── cardex.service.ts
├── cardex.service.test.ts ← unitaire
├── cardex.repository.ts
├── cardex.repository.test.ts ← intégration DB
├── cardex.routes.ts
└── cardex.routes.test.ts ← intégration ElysiaPas de dossier __tests__/. Naviguer entre code et test = 0 effort.
Splits quand > 600 lignes
Si un *.test.ts dépasse 600 lignes, on splitte par scénario :
cardex.service.test.ts → 700 lignes, on split
↓
cardex.service.create.test.ts
cardex.service.link-guest.test.ts
cardex.service.confirm-arrival.test.tsNaming imposé
Format : should <comportement> when <condition>.
test('should link guest to user when email matches a pending guest', async () => {});
test('should throw InvalidTransitionError when confirming arrival on already-arrived guest', async () => {});
test('should publish status:arrived event on Redis when confirmGuestArrival succeeds', async () => {});Pas de it("works"), pas de test("test 1"). Le nom du test EST la spec.
Les 3 flows E2E imposés
On ne multipliera pas les tests E2E inutilement. Trois parcours, pas plus, pas moins :
1. Guest check-in complet
Staff envoie email → guest clique → remplit form → créé mot de passe → valide upsells → paye (Stripe Checkout en mode test, carte 4242) → arrive sur la waiting page → staff confirme arrivée dans le cardex → PWA débloque et affiche la home.
2. Staff répond à un chat guest
Guest envoie un message dans le chat PWA → staff le voit dans /dashboard/chats → répond → le message apparaît en realtime (SSE) dans la PWA guest.
3. Commande room service payée
Guest arrivé ouvre room service → ajoute 3 items → checkout → Payment Element → confirmation → commande visible dans le dashboard staff avec statut "pending" → staff passe en "preparing" → PWA guest voit la mise à jour.
Toute autre feature (admin user management, sync Mews manuelle, ticket escalation) est testée au niveau intégration — pas en E2E.
Base de données de test
Chaque worker Bun Test qui a besoin d'une DB :
- Utilise un container Postgres
pgvector/pgvector:pg18dédié (viatestcontainers-nodeou undocker-compose.test.ymldémarré avant la CI) - Migre le schema au démarrage (
drizzle-kit pushen mémoire) - Truncate les tables entre chaque test (transaction rollback quand possible)
Helper standard :
// packages/db/src/testing/setup.ts
export async function setupTestDB() {
const db = drizzle(postgres(TEST_DATABASE_URL));
await migrate(db, { migrationsFolder: "./drizzle" });
return db;
}
export async function cleanTestDB(db: DB) {
await db.execute(sql`TRUNCATE TABLE guest, room, organization, "user", session CASCADE`);
}Dans les tests :
// packages/api/src/modules/cardex/cardex.service.test.ts
import { beforeEach, afterAll, test, expect } from "bun:test";
const db = await setupTestDB();
beforeEach(() => cleanTestDB(db));
afterAll(() => db.$client.end());
test("should link guest to user when email matches a pending guest", async () => {
const [org] = await db.insert(organization).values({ name: "Test Hotel" }).returning();
const [g] = await db.insert(guest).values({
organizationId: org.id,
email: "alice@test.com",
checkInStatus: "pending",
}).returning();
const [u] = await db.insert(user).values({ email: "alice@test.com" }).returning();
const result = await linkGuestToUser({ userId: u.id, db });
expect(result.linked).toBe(true);
expect(result.guestId).toBe(g.id);
});Tests des routes Elysia
On utilise app.handle(new Request(...)) sans passer par Eden (on teste le contrat HTTP, pas le client) :
// packages/api/src/modules/cardex/cardex.routes.test.ts
test("POST /cardex/confirm-arrival should return 200 and update status", async () => {
const { app, session } = await setupAuthenticatedApp({ role: "staff" });
const response = await app.handle(new Request("http://test/cardex/confirm-arrival", {
method: "POST",
headers: { cookie: `better-auth.session_token=${session.token}` },
body: JSON.stringify({ guestId: TEST_GUEST_ID }),
}));
expect(response.status).toBe(200);
const data = await response.json();
expect(data.alreadyArrived).toBe(false);
const [g] = await db.select().from(guest).where(eq(guest.id, TEST_GUEST_ID));
expect(g.checkInStatus).toBe("arrived");
});Tests des adapters PMS
Chaque adapter doit passer un contract test commun qui vérifie l'interface IntegrationAdapter :
// packages/api/src/services/integrations/adapters/mews.test.ts
import { runAdapterContractTests } from "../testing/contract";
import { MewsAdapter } from "./mews";
runAdapterContractTests(() => new MewsAdapter(mockedCredentials), {
expectedCapabilities: ["rooms", "guests", "reservations", "webhooks", "checkin", "checkout", "charges", "room-status"],
fixtureProperty: mewsDemoProperty,
});Le contract test vérifie : testConnection retourne bien { success, propertyName, propertyId }, chaque capability déclarée est implémentée, les normalized returns matchent les types, etc. Ça force le respect du contrat pour chaque nouvel adapter (Opera, Cloudbeds).
Règles absolues
- Toute fonction de state machine → test unitaire de chaque transition (autorisée ET interdite)
- Toute route Elysia mutante → test d'intégration avec vraie DB
- Tout adapter PMS → contract test + happy path
- Tout bug fix → ouvre avec un test qui reproduit le bug, puis le code qui le corrige
- PR sans tests sur ces points = rejetée
CI
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_PASSWORD: test
ports: [5432:5432]
redis:
image: redis:7
ports: [6379:6379]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test --coverage
- run: bun run check-types
- run: bun run check # biome
- run: bunx playwright test # 3 flows E2EPR bloquée si un test fail, un type check fail, ou biome râle.
Conséquences
Positives :
- Filet de sécurité solide pour les refactos (et pour l'IA qui touche au code)
- Tests rapides (Bun Test est 10× plus rapide que Jest)
- Pas d'outils complexes à maîtriser, pas de Jest/Vitest/Mocha
- Contract tests des adapters → nouveaux PMS intégrés sans régressions
Négatives :
- Discipline requise sur le naming et la colocation
- Test DB Postgres en CI ajoute ~20s de warmup (acceptable)
- Playwright setup initial prend 1 journée
Métriques à surveiller
- Coverage unitaire des modules
packages/api/src/domain/**(cible : 100 %) - Coverage intégration des modules
packages/api/src/modules/**(cible : 80 %) - Temps total d'exécution de la CI (cible : < 5 min)
- Taux de PR rejetées pour cause de tests manquants (cible : < 10 % après 1 mois de discipline)
- Taux de régressions en prod détectées hors CI (cible : proche de 0)