Tests
Stratégie, pyramide, outils, colocation, naming imposé
Tout ce qui est codé métier est testé. Pas de PR sans tests pour les fonctions métier, pas d'exception. Référence : ADR-06.
Le contrat
Bell est un projet long-terme avec une équipe qui va grandir et une IA qui modifie du code. Sans tests, la première refacto casse silencieusement le métier et personne ne le voit avant le client.
Règle absolue :
- Toute fonction de logique métier dans un service est testée
- Toute transition d'état (state machine) est testée
- Toute règle de calcul (prix, commission, matching) est testée
- Tout bug fix ouvre avec un test qui reproduit le bug, puis le code qui le corrige
- Toute route Elysia mutante (POST/PUT/DELETE/PATCH) a un test d'intégration
- Tout adapter PMS passe un contract test
Une PR sans tests sur ces points est rejetée par le reviewer.
La pyramide
| Niveau | Vitesse | Coverage cible | Quoi |
|---|---|---|---|
| Unitaires | < 50 ms | 90 %+ sur services, 100 % sur machines | fonctions pures, helpers, calculs |
| Intégration | 100-500 ms | 80 %+ sur routes | services avec DB, routes via app.handle(), adapters avec API mockée |
| E2E | 5-30 s | 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 bell_test (voir environments) |
| drizzle-seed | Fixtures cohérentes pour les tests |
| msw | Mock HTTP pour tester les adapters PMS sans hit Mews/Stripe |
| Playwright | E2E uniquement |
app.handle(new Request(...)) | Appel Elysia direct en test, pas Eden client |
Pas de Vitest, pas de Jest, pas de Mocha.
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 __tests__/. Naviguer entre code et test = 0 effort.
Splits quand > 600 lignes
cardex.service.test.ts → 720 lignes, on splitte
↓
cardex.service.link-guest.test.ts
cardex.service.confirm-arrival.test.ts
cardex.service.send-email.test.ts
cardex.service.crud.test.tsNaming imposé
Format : should <comportement> when <condition>.
test("should link guest to user when email matches a pending guest", async () => {});
test("should throw InvalidCheckInTransitionError when confirming arrival on arrived guest", async () => {});
test("should publish status:arrived Redis event when confirmGuestArrival succeeds", async () => {});
test("should enqueue bridgeCheckIn job when arrival is confirmed", async () => {});Pas de it("works"), pas de test("test 1"). Le nom du test EST la spec.
Setup DB de test
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { sql } from "drizzle-orm";
const TEST_URL = process.env.DATABASE_TEST_URL ??
"postgresql://bell:bell_dev_password@localhost:5432/bell_test";
export async function setupTestDB() {
const pool = new Pool({ connectionString: TEST_URL });
const db = drizzle(pool);
await migrate(db, { migrationsFolder: "./packages/db/migrations" });
return { db, pool };
}
export async function cleanTestDB(db: DB) {
await db.execute(sql`
TRUNCATE TABLE
guest, guest_group, room, restaurant_booking, room_service_order,
laundry_order, spa_booking, conversation, message, ticket, ticket_comment,
staff_note, note_comment, integration, integration_sync_log, member, "user",
session, organization
CASCADE
`);
}Dans un test :
import { beforeEach, afterAll, test, expect } from "bun:test";
import { setupTestDB, cleanTestDB } from "@bell/db/testing/setup";
const { db, pool } = await setupTestDB();
beforeEach(() => cleanTestDB(db));
afterAll(() => pool.end());
test("should ...", async () => {
// setup fixtures, act, assert
});Exemples
Test unitaire — state machine
import { describe, test, expect } from "bun:test";
import { checkInStatusEnum } from "@bell/db/schema/enums";
import {
canTransitionCheckIn,
assertTransitionCheckIn,
InvalidCheckInTransitionError,
checkInTransitions,
} from "./check-in.machine";
describe("check-in state machine", () => {
// Matrice exhaustive
for (const from of checkInStatusEnum.enumValues) {
for (const to of checkInStatusEnum.enumValues) {
const allowed = checkInTransitions[from].includes(to as never);
test(`should ${allowed ? "allow" : "forbid"} ${from} → ${to}`, () => {
expect(canTransitionCheckIn(from, to)).toBe(allowed);
});
}
}
test("should throw InvalidCheckInTransitionError on forbidden transition", () => {
expect(() => assertTransitionCheckIn("arrived", "pending"))
.toThrow(InvalidCheckInTransitionError);
});
test("should have a transition rule for every checkInStatus enum value", () => {
for (const status of checkInStatusEnum.enumValues) {
expect(checkInTransitions[status]).toBeDefined();
}
});
});Test intégration — service
import { beforeEach, test, expect, mock } from "bun:test";
import { setupTestDB, cleanTestDB } from "@bell/db/testing/setup";
import { guest, room, organization, user, member } from "@bell/db/schema";
import { confirmGuestArrival } from "./cardex.service";
const { db } = await setupTestDB();
beforeEach(() => cleanTestDB(db));
async function seedOrgWithGuest(status: "completed" | "arrived" = "completed") {
const [org] = await db.insert(organization).values({ name: "Test Hotel", slug: "test" }).returning();
const [u] = await db.insert(user).values({ email: "alice@test.com", name: "Alice" }).returning();
await db.insert(member).values({ userId: u.id, organizationId: org.id, role: "guest" });
const [r] = await db.insert(room).values({
organizationId: org.id,
roomNumber: "101",
floor: 1,
roomType: "double",
status: "reserved",
pricePerNight: "150",
amenities: {},
capacity: 2,
}).returning();
const [g] = await db.insert(guest).values({
organizationId: org.id,
userId: u.id,
roomId: r.id,
firstName: "Alice",
lastName: "Test",
email: "alice@test.com",
checkInStatus: status,
}).returning();
return { org, u, r, g };
}
test("should mark guest as arrived and room as occupied", async () => {
const { org, g } = await seedOrgWithGuest("completed");
const result = await confirmGuestArrival({
organizationId: org.id,
guestId: g.id,
});
expect(result.success).toBe(true);
expect(result.alreadyArrived).toBe(false);
const [updated] = await db.select().from(guest).where(eq(guest.id, g.id));
expect(updated.checkInStatus).toBe("arrived");
expect(updated.checkInArrivedAt).not.toBeNull();
const [updatedRoom] = await db.select().from(room).where(eq(room.id, updated.roomId!));
expect(updatedRoom.status).toBe("occupied");
});
test("should throw InvalidCheckInTransitionError when already arrived", async () => {
const { org, g } = await seedOrgWithGuest("arrived");
await expect(
confirmGuestArrival({ organizationId: org.id, guestId: g.id }),
).rejects.toThrow(/invalid check-in transition/i);
});Test intégration — route Elysia
import { test, expect, beforeEach } from "bun:test";
import { app } from "../../index";
import { setupAuthenticatedApp, cleanTestDB } from "../../testing";
beforeEach(() => cleanTestDB());
test("POST /cardex/confirm-arrival should return 200 and update DB", async () => {
const { session, guestId } = await setupAuthenticatedApp({
role: "staff",
seedGuest: { checkInStatus: "completed" },
});
const response = await app.handle(
new Request("http://test/cardex/confirm-arrival", {
method: "POST",
headers: {
cookie: `better-auth.session_token=${session.token}`,
"content-type": "application/json",
},
body: JSON.stringify({ guestId }),
}),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
});
test("POST /cardex/confirm-arrival should return 403 when called by a guest", async () => {
const { session, guestId } = await setupAuthenticatedApp({
role: "guest",
seedGuest: { checkInStatus: "completed" },
});
const response = await app.handle(
new Request("http://test/cardex/confirm-arrival", {
method: "POST",
headers: {
cookie: `better-auth.session_token=${session.token}`,
"content-type": "application/json",
},
body: JSON.stringify({ guestId }),
}),
);
expect(response.status).toBe(403);
});Test adapter PMS — contract
Helper partagé pour tous les adapters :
export function runAdapterContractTests(config: {
name: string;
build: () => IntegrationAdapter;
expectedCapabilities: AdapterCapability[];
mocks: Record<string, unknown>; // MSW handlers
}) {
describe(`${config.name} - contract`, () => {
beforeAll(() => setupMSW(config.mocks));
afterAll(() => teardownMSW());
test("should declare expected capabilities", () => {
const adapter = config.build();
for (const cap of config.expectedCapabilities) {
expect(adapter.capabilities.has(cap)).toBe(true);
}
});
test("should implement every declared capability", () => {
const adapter = config.build();
if (adapter.capabilities.has("rooms")) expect(adapter.getRooms).toBeDefined();
if (adapter.capabilities.has("guests")) expect(adapter.getGuests).toBeDefined();
if (adapter.capabilities.has("reservations")) expect(adapter.getReservations).toBeDefined();
if (adapter.capabilities.has("webhooks")) {
expect(adapter.parseWebhookEvent).toBeDefined();
expect(adapter.verifyWebhookSignature).toBeDefined();
}
if (adapter.capabilities.has("checkin")) expect(adapter.startReservation).toBeDefined();
if (adapter.capabilities.has("checkout")) expect(adapter.processReservation).toBeDefined();
if (adapter.capabilities.has("charges")) expect(adapter.addOrder).toBeDefined();
if (adapter.capabilities.has("room-status")) expect(adapter.updateRoomStatus).toBeDefined();
});
test("testConnection should return success/propertyName/propertyId", async () => {
const adapter = config.build();
const res = await adapter.testConnection();
expect(res).toMatchObject({
success: expect.any(Boolean),
propertyName: expect.any(String),
propertyId: expect.any(String),
});
});
test("getRooms should return valid NormalizedRoom[]", async () => {
const adapter = config.build();
if (!adapter.capabilities.has("rooms")) return;
const rooms = await adapter.getRooms!();
for (const r of rooms) {
expect(r.externalId).toEqual(expect.any(String));
expect(r.number).toEqual(expect.any(String));
expect(["available", "occupied", "cleaning", "maintenance", "reserved"]).toContain(r.status);
}
});
// Idem pour getGuests, getReservations, etc.
});
}Usage :
// packages/api/src/services/integrations/adapters/mews.test.ts
runAdapterContractTests({
name: "MewsAdapter",
build: () => new MewsAdapter(mewsFixtures.credentials, mewsFixtures.platformUrl),
expectedCapabilities: ["rooms", "guests", "reservations", "webhooks",
"checkin", "checkout", "charges", "room-status"],
mocks: mewsFixtures.handlers,
});Le même helper test tous les futurs adapters (Opera, Cloudbeds) sans code dupliqué.
Tests E2E imposés
3 parcours Playwright, pas plus, pas moins. ADR-06.
- Guest check-in complet — email → PWA → pay → staff confirm → PWA unlocked
- Staff répond à un chat guest — guest msg → staff reply via dashboard → guest voit en realtime (SSE)
- Commande room service payée — guest → Stripe test → staff → status updated → guest voit
Scripts dans tests/e2e/. CI lance Playwright en container séparé.
CI
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: bell_test
POSTGRES_USER: bell
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 10s
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with: { bun-version: 1.3.8 }
- run: bun install --frozen-lockfile
- run: bun run check-types
- run: bun run check # biome
- run: bun test --coverage
- run: scripts/check-file-sizes.sh # 500 lignes max
- run: bunx playwright install --with-deps
- run: bunx playwright testCe qu'on ne teste PAS
- Composants UI purs sans logique (juste JSX + props) → validation visuelle via
/designshowcase - Helpers triviaux (
formatDate,cn) → l'inférence TS valide - Config files (
next.config,drizzle.config) → validés à l'usage - AI real calls à Azure OpenAI → mocké (sinon flaky + coûteux)
Règles pour PR
Rejetée si :
- Un fichier métier a été modifié sans test associé modifié/ajouté
- Le coverage unitaire des
domain/**chute en dessous de 100 % - Le coverage intégration des
modules/**chute en dessous de 80 % - Un test est commenté/skippé sans justification dans la PR description
- Un nouveau adapter PMS n'a pas de contract test