My App

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 (arrived puis 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

NiveauVitesseCoverage cibleQuoi
Unitaires< 50 ms / test90 %+ sur les machines, 80 %+ sur les servicesState machines, helpers, calculs métier, validations
Intégration100–500 ms / test80 %+ sur les routesServices avec vraie DB, routes Elysia via app.handle(), adapters avec API mockée
E2E5–30 s / test3 parcours imposésPlaywright

Stack

OutilRôle
Bun TestRunner principal — natif Bun, 10× plus rapide que Jest
Postgres docker testDB éphémère par worker Bun (isolation parfaite, voir plus bas)
drizzle-seedSeed de fixtures par test
msw (futur)Mock HTTP pour les tests adapter PMS
PlaywrightE2E uniquement
Pas de Vitest, Jest, Mocha, ni Eden côté testBun 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 Elysia

Pas 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.ts

Naming 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 :

  1. Utilise un container Postgres pgvector/pgvector:pg18 dédié (via testcontainers-node ou un docker-compose.test.yml démarré avant la CI)
  2. Migre le schema au démarrage (drizzle-kit push en mémoire)
  3. 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 E2E

PR 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)

On this page