Reciprok Docs

Tests

Stratégie de tests, pyramide, stack, mocking, coverage

Tout ce qui est codé est testé. Pas de PR sans tests pour les fonctions métier, pas d'exception. Le test n'est pas une corvée à la fin, il est partie intégrante du développement.

Le contrat

Reciprok 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 (status d'une demande, pipeline d'un membre) est testée
  • Toute règle de calcul (commission, scoring, matching) est testée
  • Tout bug fix ouvre avec un test qui reproduit le bug, puis le code qui le corrige
  • Toute route critique (auth, écriture DB, action IA) a un test d'intégration

Une PR sans tests sur ces points est rejetée systématiquement.

La pyramide

NiveauVitesseCoverage cibleQuoi
Unitaires< 50ms / test90 %+ sur les servicesFonctions pures, calculs métier, helpers, validations
Intégration100-500ms / test80 %+ sur les routesServices avec vraie DB, routes Elysia via app.handle()
E2E5-30s / testParcours critiques uniquementPlaywright sur les flows clés (V2+)

Pas de test inversé : si tu écris 10 tests E2E avant 1 test unitaire, tu es à l'envers. Les unitaires sont rapides, isolés, fiables, c'est là qu'on couvre le gros du métier.

Stack

OutilRôle
Bun TestRunner principal, natif Bun, hyper rapide, compatible Jest
Postgres test containerDB éphémère pour les tests d'intégration (CI utilise une vraie Postgres pgvector)
@elysiajs/edenPas utilisé en test, on appelle directement app.handle(new Request(...))
drizzle-seedGénération de données de test cohérentes
MSW (futur)Mock des appels HTTP externes côté frontend
Playwright (V2+)E2E sur les parcours critiques

Pas de Vitest, pas de Jest, pas de Mocha. Bun Test partout.

Conventions de fichiers

Tests colocalisés

Toujours à côté du fichier qu'ils testent, suffixe .test.ts :

modules/members/
├── members.service.ts
├── members.service.test.ts          ← unit
├── members.repository.ts
├── members.repository.test.ts       ← intégration (DB)
├── members.routes.ts
└── members.routes.test.ts           ← intégration (Elysia handle)

Pas de dossier __tests__/ séparé. Naviguer entre code et test doit prendre 0 effort.

Splitter les fichiers de test longs

Quand un test file dépasse 600 lignes, on splitte par groupe de scénarios :

members.service.test.ts                  → 700 lignes, on splitte

members.service.create.test.ts           ← scénarios de création
members.service.search.test.ts           ← scénarios de recherche
members.service.update.test.ts           ← scénarios d'update

(Cf. Seuils de fichiers, les test files ont un plafond plus permissif mais pas illimité.)

Naming des tests

Format imposé : should <comportement attendu> when <condition>.

test('should return null when member does not exist', async () => { /* */ });
test('should throw MemberInactiveError when member is inactive', async () => { /* */ });
test('should advance status from "qualifying" to "searching" when description is set', async () => { /* */ });

Pas de it('works'), pas de test('test 1'). Le nom du test est la spec.

Structure d'un test

AAA : Arrange, Act, Assert. Une ligne vide entre chaque.

test('should calculate commission with new client rate when isFirstClient is true', () => {
  // Arrange
  const request = makeRequest({ amount: 1000, isFirstClient: true });
  const rule = makeCommissionRule({ defaultRate: 0.10, newClientRate: 0.15 });

  // Act
  const commission = calculateCommission(request, rule);

  // Assert
  expect(commission.amount).toBe(150);
  expect(commission.rateReason).toBe('new_client');
});

Si un test fait plus de 25 lignes, c'est probablement deux tests qui se cachent.

Quoi tester (par couche)

Services (unitaire ou intégration)

Tester :

  • Toutes les règles métier (calcul, validation, transitions d'état)
  • Tous les chemins d'erreur (throws Error)
  • Les effets de bord (emit de domain events, appels à d'autres services)

Ne pas tester :

  • L'implémentation interne (variables locales, ordre d'exécution)
  • Le code framework (Drizzle, Elysia, Anthropic SDK)
// ✅
test('should emit "request.member_added_to_results" when adding a member', async () => {
  const emitSpy = mock(emit);
  await requestsService.addMember(reqId, memberId, { actorId: userId });
  expect(emitSpy).toHaveBeenCalledWith('request.member_added_to_results', expect.objectContaining({ memberId }), expect.anything());
});

// ❌ Test inutile
test('should call db.insert', async () => {
  const insertSpy = mock(db.insert);
  await requestsService.create({ /* ... */ });
  expect(insertSpy).toHaveBeenCalled();
});

Repositories (intégration uniquement)

Les repositories sont trop simples pour mériter des tests unitaires (juste des appels Drizzle). On les teste en intégration avec une vraie DB :

test('findById should return the member with its rooms', async () => {
  const member = await seed.member({ name: 'Loft 42' });
  await seed.room({ memberId: member.id, name: 'Salle A' });

  const found = await membersRepository.findById(member.id);

  expect(found?.name).toBe('Loft 42');
  expect(found?.rooms).toHaveLength(1);
});

Routes Elysia (intégration)

On appelle directement app.handle(new Request(...)), pas besoin de serveur HTTP, pas besoin de supertest.

import { app } from '@/app';

test('POST /requests should return 201 with the created request', async () => {
  const res = await app.handle(
    new Request('http://localhost/api/v1/requests', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', cookie: validSessionCookie },
      body: JSON.stringify({ description: 'test', participantsCount: 50 }),
    }),
  );

  expect(res.status).toBe(201);
  const body = await res.json();
  expect(body).toMatchObject({ description: 'test', participantsCount: 50 });
});

Composants React

Tester le comportement utilisateur, pas les détails d'implémentation. Utiliser React Testing Library + Bun Test.

test('should call onClick when the user clicks the button', () => {
  const handleClick = mock();
  render(<MemberCard member={mockMember} onClick={handleClick} />);

  fireEvent.click(screen.getByRole('button', { name: /voir le membre/i }));

  expect(handleClick).toHaveBeenCalledTimes(1);
});

Ne pas tester :

  • Le rendu HTML exact (snapshot brittle)
  • Les classes CSS Tailwind
  • Les props internes

Hooks React

Tester via renderHook de React Testing Library.

test('useDebounce should return the latest value after the delay', async () => {
  const { result, rerender } = renderHook(({ v }) => useDebounce(v, 100), { initialProps: { v: 'a' } });
  rerender({ v: 'b' });
  expect(result.current).toBe('a');
  await new Promise((r) => setTimeout(r, 110));
  expect(result.current).toBe('b');
});

Mocking, minimum vital

Mocker uniquement les services externes : Anthropic, OpenAI, Resend, Meta WhatsApp, R2.

Pas de mock de Postgres, pas de mock de Drizzle, pas de mock de fonctions internes.

Pourquoi pas de mock DB

On a déjà fait l'expérience ailleurs : les tests qui mockent Drizzle passent en CI mais cassent en prod parce que la requête réelle ne fait pas ce qu'on croit. Toujours tester contre une vraie Postgres.

En CI, on a un service Postgres pgvector qui tourne (cf. GitHub Actions CI/CD). En local, on utilise la même Postgres que pour le dev (ou un container dédié bun run docker:test).

Mocks de services externes

Wrapper centralisé pour chaque provider, mockable en bloc :

lib/anthropic-mock.ts
export const anthropicMock = {
  messages: {
    create: mock(async () => ({
      content: [{ type: 'text', text: 'mocked response' }],
      stop_reason: 'end_turn',
    })),
    stream: mock(async function* () {
      yield { type: 'message_delta' };
    }),
  },
};
import { anthropicMock } from '@/lib/anthropic-mock';

beforeEach(() => {
  globalAnthropic = anthropicMock as unknown as Anthropic;
});

Le wrapper évite que chaque test ait à mocker manuellement, et garantit qu'on a un faux comportement réaliste.

Stub des emails

En test, on remplace Resend par un stub qui enregistre les emails envoyés au lieu de les envoyer :

export const emailStub = {
  sent: [] as Array<{ to: string; subject: string; body: string }>,
  send(args: { to: string; subject: string; body: string }) {
    this.sent.push(args);
  },
  reset() {
    this.sent = [];
  },
};

Puis dans les tests :

test('should send a catalog email to the organizer', async () => {
  emailStub.reset();
  await catalogService.sendCatalog(catalogId);
  expect(emailStub.sent).toHaveLength(1);
  expect(emailStub.sent[0].to).toBe(organizer.email);
});

Données de test, fixtures et factories

Factories

Fonctions pures qui créent des objets cohérents avec des valeurs par défaut, surchargeables :

test/factories/member.factory.ts
import type { Member } from '@reciprok/db/schema';
import { faker } from '@faker-js/faker/locale/fr';

export function makeMember(overrides: Partial<Member> = {}): Member {
  return {
    id: faker.string.uuid(),
    code: `#${faker.number.int({ min: 100, max: 999 })}`,
    name: faker.company.name(),
    description: faker.lorem.paragraph(),
    postalCode: faker.location.zipCode('#####'),
    status: 'active',
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

Usage :

const member = makeMember({ status: 'inactive' });

Seeders (intégration)

Pour les tests d'intégration, on insère vraiment en base. Helper colocalisé :

test/seed.ts
import { db } from '@reciprok/db';
import { member } from '@reciprok/db/schema';
import { makeMember } from './factories/member.factory';

export const seed = {
  async member(overrides: Partial<typeof member.$inferInsert> = {}) {
    const data = makeMember(overrides);
    const [created] = await db.insert(member).values(data).returning();
    return created;
  },

  async request(overrides: Partial<typeof request.$inferInsert> = {}) {
    /* ... */
  },
};

Reset entre les tests

Chaque test démarre avec une base propre. Stratégie : TRUNCATE toutes les tables (rapide, ~5ms) avant chaque test d'intégration.

test/setup.ts
import { beforeEach } from 'bun:test';
import { db } from '@reciprok/db';
import { sql } from 'drizzle-orm';

beforeEach(async () => {
  await db.execute(sql`TRUNCATE TABLE member, request, organizer, ... RESTART IDENTITY CASCADE`);
});

Alternative : transactions imbriquées avec rollback. Plus propre mais plus complexe à mettre en place côté Drizzle. À considérer si TRUNCATE devient lent.

Couverture (coverage)

Bun supporte le coverage natif :

bun test --coverage

Cibles minimales

ZoneCouverture cible
apps/server/src/modules/*/*.service.ts90 %+
apps/server/src/modules/*/*.routes.ts80 %+ (intégration)
apps/server/src/lib/*85 %+
packages/db/src/*.tsnon mesuré (testé via les services)
Composants React métier70 %+
Composants UI shadcnnon testé (déjà testé upstream)

Pas une religion

La couverture est un indicateur, pas un objectif. Un module à 95 % de couverture peut avoir des trous critiques (les chemins d'erreur), un module à 60 % peut couvrir tous les cas qui comptent.

Ce qui compte vraiment : est-ce qu'un nouveau dev peut introduire une régression sans qu'aucun test ne casse ? Si oui, il manque des tests, peu importe le pourcentage affiché.

Tests dans le workflow de dev

TDD léger (recommandé, pas obligatoire)

Pour les fonctions de calcul métier (commission, transitions d'état, matching) :

  1. Écris le test avec le comportement attendu
  2. Lance-le, regarde-le échouer
  3. Implémente le minimum pour qu'il passe
  4. Refacto

Le bénéfice : tu conçois ton API publique avant l'implémentation, ce qui donne presque toujours un meilleur design.

Tests à l'ouverture d'un bug

Workflow obligatoire quand on corrige un bug :

  1. Ouvrir le test qui aurait dû attraper le bug
  2. Le faire échouer en reproduisant le bug
  3. Corriger le code
  4. Le test passe

C'est la seule façon de s'assurer que le bug ne reviendra pas dans 6 mois.

Tests en CI

Le job test du workflow ci.yml (cf. GitHub Actions CI/CD) :

  • Démarre une vraie Postgres pgvector
  • Applique les migrations
  • Lance bun test
  • Le job échoue si un seul test rate

Pas de test.skip mergé sur develop ou main. Si un test est légitimement skippé, il a une issue associée et un commentaire qui pointe dessus :

// SKIPPED: dépend de l'API Meta réelle, à reactiver après mock, voir #142
test.skip('should handle WhatsApp inbound media', async () => { /* */ });

Anti-patterns à proscrire

1. Le test qui retourne expect(true).toBe(true)

test('something', () => {
  doStuff();
  expect(true).toBe(true);
});

Inutile. Si tu n'as rien à asserter, tu n'as pas de test. Soit tu trouves quoi vérifier, soit tu supprimes.

2. Le test qui mock tout

test('createMember', async () => {
  const dbMock = mock();
  const validatorMock = mock(() => true);
  const eventEmitterMock = mock();
  const result = await createMember({ db: dbMock, validator: validatorMock, ...});
  expect(dbMock).toHaveBeenCalled();
});

Le test ne teste rien de réel. Il vérifie juste que les fonctions sont appelées dans un certain ordre. La première refacto le casse sans qu'aucun bug réel n'apparaisse.

3. Les snapshots brittle

expect(component).toMatchSnapshot();

À éviter. Les snapshots cassent à chaque changement de className et personne ne lit le diff. Préférer des assertions ciblées (getByText, getByRole).

4. Tests qui dépendent de l'ordre

Chaque test doit être indépendant. Si un test échoue en isolation alors qu'il passe en suite, c'est qu'il dépend d'un état laissé par le test précédent → bug à corriger.

// ❌
test('test A creates a member', async () => { /* crée member 42 */ });
test('test B reads member 42', async () => { /* dépend de A */ });

// ✅ Chaque test crée ses propres données
test('test A creates a member', async () => { const m = await seed.member(); /* */ });
test('test B reads a member', async () => { const m = await seed.member(); /* */ });

5. Tests qui valident les détails d'implémentation

// ❌
test('should call db.query.member.findFirst', async () => {
  const spy = mock(db.query.member, 'findFirst');
  await membersService.byId('123');
  expect(spy).toHaveBeenCalled();
});

Le test casse au moindre refacto alors que le comportement est correct. Tester le résultat, pas le moyen.

// ✅
test('should return the member when it exists', async () => {
  const m = await seed.member({ id: '123', name: 'Test' });
  const found = await membersService.byId('123');
  expect(found?.name).toBe('Test');
});

E2E (V2+)

Pas dans le scope MVP / V1. Quand on l'ajoutera :

  • Playwright sur Chromium uniquement (pas de Safari/Firefox au démarrage)
  • 5-10 tests max : login → création demande → recherche → catalogue envoyé → gagner. C'est tout. Les E2E coûtent cher en maintenance, on les garde pour les parcours qu'on doit tenir absolument.
  • Lancés en CI dans un job séparé et non bloquant au début (warning), bloquant après 1 mois de stabilité

Voir aussi

On this page