My App

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

NiveauVitesseCoverage cibleQuoi
Unitaires< 50 ms90 %+ sur services, 100 % sur machinesfonctions pures, helpers, calculs
Intégration100-500 ms80 %+ sur routesservices avec DB, routes via app.handle(), adapters avec API mockée
E2E5-30 s3 parcours imposésPlaywright

Stack

OutilRôle
Bun TestRunner principal — natif Bun, 10× plus rapide que Jest
Postgres docker testDB éphémère bell_test (voir environments)
drizzle-seedFixtures cohérentes pour les tests
mswMock HTTP pour tester les adapters PMS sans hit Mews/Stripe
PlaywrightE2E 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 Elysia

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

Naming 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

packages/db/src/testing/setup.ts
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

packages/api/src/domain/check-in.machine.test.ts
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

packages/api/src/modules/cardex/cardex.service.confirm-arrival.test.ts
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

packages/api/src/modules/cardex/cardex.routes.test.ts
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 :

packages/api/src/services/integrations/testing/contract.ts
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.

  1. Guest check-in complet — email → PWA → pay → staff confirm → PWA unlocked
  2. Staff répond à un chat guest — guest msg → staff reply via dashboard → guest voit en realtime (SSE)
  3. 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

.github/workflows/ci.yml
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 test

Ce qu'on ne teste PAS

  • Composants UI purs sans logique (juste JSX + props) → validation visuelle via /design showcase
  • 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

Lien

On this page