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
| Niveau | Vitesse | Coverage cible | Quoi |
|---|---|---|---|
| Unitaires | < 50ms / test | 90 %+ sur les services | Fonctions pures, calculs métier, helpers, validations |
| Intégration | 100-500ms / test | 80 %+ sur les routes | Services avec vraie DB, routes Elysia via app.handle() |
| E2E | 5-30s / test | Parcours critiques uniquement | Playwright 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
| Outil | Rôle |
|---|---|
| Bun Test | Runner principal, natif Bun, hyper rapide, compatible Jest |
| Postgres test container | DB éphémère pour les tests d'intégration (CI utilise une vraie Postgres pgvector) |
@elysiajs/eden | Pas utilisé en test, on appelle directement app.handle(new Request(...)) |
drizzle-seed | Gé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 :
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 :
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é :
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.
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 --coverageCibles minimales
| Zone | Couverture cible |
|---|---|
apps/server/src/modules/*/*.service.ts | 90 %+ |
apps/server/src/modules/*/*.routes.ts | 80 %+ (intégration) |
apps/server/src/lib/* | 85 %+ |
packages/db/src/*.ts | non mesuré (testé via les services) |
| Composants React métier | 70 %+ |
| Composants UI shadcn | non 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) :
- Écris le test avec le comportement attendu
- Lance-le, regarde-le échouer
- Implémente le minimum pour qu'il passe
- 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 :
- Ouvrir le test qui aurait dû attraper le bug
- Le faire échouer en reproduisant le bug
- Corriger le code
- 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
- Architecture modulaire, où placer les tests
- Seuils de fichiers, limites pour les test files
- Git workflow, tests requis avant merge
- Operations / Environments, services Postgres en CI