Tests d'intégration¶
Tests qui montent le vrai AppModule (Nest + Fastify) contre une Postgres et une Redis réelles
en conteneurs : on couvre le chemin complet — route, validation, guards, services, SQL, cache —
avec MSW à la frontière réseau : tout appel HTTP externe non mocké fait échouer le test
(le loopback de supertest passe).
En bref
Une vraie DB + un vrai cache en Docker, l'app bootée une seule fois, et la base nettoyée entre chaque test. Objectif : tester l'API « pour de vrai » et qu'ajouter un test prenne ~30 secondes.
Sources
- Harness :
projects/api/tests/integration/ - Boot session :
core/global-setup.ts·core/setup-file.ts - Infra :
docker/docker-compose.integration.yml·docker/snapshot.sql
🚀 Lancer¶
Prérequis
Docker/OrbStack lancé + accès Doppler (config dev_integration_tests, projet api) — source
unique de l'env de test. Premier run : doppler login.
| Commande | Usage |
|---|---|
pnpm --filter @bricks/api test:integration |
One-shot : up → vitest → teardown (run complet / CI) |
pnpm --filter @bricks/api test:integration:watch |
Boucle rapide : stack + app restent chauds |
pnpm --filter @bricks/api test:integration:ui |
Vitest UI (timings par test, re-run interactif) |
pnpm --filter @bricks/api test:integration:up |
Conteneurs seuls (debug DB sur localhost:5432) |
pnpm --filter @bricks/api test:integration:down |
Teardown propre (-v) |
Le play button Vitest et le Test Explorer marchent sans :up préalable — global-setup.ts
démarre le stack en idempotent et récupère Doppler tout seul.
🔄 Comment ça marche¶
Une session = un coût fixe au démarrage (docker up + import snapshot + Flyway + boot AppModule),
pas de travail par test. L'app est montée 1× par session ; l'isolation se fait en vidant les
données entre chaque test.
flowchart TD
Run([pnpm test:integration<br/>ou ▶ play button]):::endpoint
Doppler[loadDopplerEnv<br/>DATABASE_URL / REDIS_URL]:::action
Check{docker info<br/>daemon up ?}
Fail[message FR clair]:::failure
Up[docker compose up<br/>Postgres + Redis --wait]:::action
Snap[postgres-init.sh<br/>importe snapshot.sql ~1s]:::status
Migrate[flyway migrate<br/>delta de migrations]:::status
Poll[poll Postgres + Redis]:::action
BeforeAll[beforeAll 1×/fichier<br/>boot AppModule + DataSource + Redis]:::action
BeforeEach[beforeEach chaque test<br/>resetAll DB + flushall Redis + MSW reset + email spy]:::status
Test([it → req → assertions]):::success
Run --> Doppler --> Check
Check -- non --> Fail
Check -- oui --> Up --> Snap --> Migrate --> Poll --> BeforeAll --> BeforeEach --> Test
Test -. test suivant .-> BeforeEach
classDef endpoint fill:#4f46e5,color:#fff,stroke:#3730a3
classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef status fill:#fef3c7,color:#78350f,stroke:#d97706
classDef success fill:#10b981,color:#fff,stroke:#047857
classDef failure fill:#ef4444,color:#fff,stroke:#b91c1c
Pourquoi un DELETE ciblé plutôt que TRUNCATE/rollback ?
DbHelper.resetAll() ne vide que les tables réellement écrites, sous
session_replication_role = 'replica' (triggers FK off, ordre indifférent) → ~15 ms vs
~485 ms pour un TRUNCATE … CASCADE. Les connexions restent ouvertes ; chaque test démarre sur
une DB propre, sans dépendre de l'ordre d'exécution.
🗄️ Snapshot & Flyway¶
fixtures/snapshot.sql = un baseline (schéma + __flyway_schema_history__, les migrations
1…N déjà jouées), produit par pg_dump. Au boot, Postgres importe ce baseline (~1 s), puis Flyway
applique uniquement le delta N+1…M. On évite de rejouer toutes les migrations from scratch.
flowchart LR
Snap[snapshot.sql<br/>schéma + historique 1…N]:::status
DB[(Postgres)]:::action
Delta[migrations N+1…M<br/>delta récent]:::status
New[nouveau snapshot 1…M]:::success
Snap -->|import ~1s| DB
Delta -->|flyway migrate| DB
DB -. test:integration:snapshot<br/>de temps en temps .-> New
New -. remplace .-> Snap
classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef status fill:#fef3c7,color:#78350f,stroke:#d97706
classDef success fill:#10b981,color:#fff,stroke:#047857
Régénérer le snapshot
Pas à chaque migration (Flyway rejoue le delta au boot, donc une migration nouvelle marche sans rien régénérer). De temps en temps, quand les migrations s'accumulent et ralentissent le cold start :
🗂️ Arborescence¶
projects/api/
├─ src/<module>/__tests__/<feature>.integration-test.ts ← les tests, COLOCALISÉS avec la prod
└─ tests/integration/
├─ core/ ← entrées Vitest
│ ├─ global-setup.ts ← 1×/session : docker up + flyway + poll
│ └─ setup-file.ts ← beforeAll boot app / beforeEach reset
├─ docker/ ← infra conteneur
│ ├─ docker-compose.integration.yml ← Postgres + Redis + Flyway
│ ├─ postgres.Dockerfile / postgres-init.sh / flyway.Dockerfile
│ └─ snapshot.sql ← baseline schéma (pg_dump, régénérable)
├─ scripts/ ← ensure-docker.sh, regenerate-snapshot.sh
├─ fixtures/<module>/<entity>.build.ts ← factory pur (model-mock)
├─ fixtures/<module>/<entity>.seed.ts ← insert via le VRAI repo prod (pas de SQL brut)
└─ helpers/ ← app/, auth/, db/, providers/ (MSW, email spy), poll
⚡ Ajouter un test¶
Pattern : describe('METHOD /route') → describe('When …') → it('Then …'). On choisit une
identité (helper auth), on seed une fixture si besoin, on tape req(), on valide la réponse.
describe('POST /api/auth/sign-up/email', () => {
describe('When email is unique', () => {
it('Then persists user + credential account and returns 200', async () => {
const { email, password, name } = buildBetterAuthUser()
await req().post('/api/auth/sign-up/email').send({ email, password, name }).expect(200)
const user = await BetterAuthRepository.findUserByEmail(email)
expect(user).toMatchObject({ email, name, emailVerified: false, role: 'user' })
})
})
})
describe('GET /administration/home/news', () => {
describe('When the caller is an admin', () => {
it('Then it returns the home news list including the seeded item', async () => {
const admin = await TestAuthHelper.createAdmin()
const seeded = await seedHomeNews({ name: 'Actu test' })
const res = await req().get(getAdminHomeNewsEndpoint.request.path())
.set('Cookie', admin.cookieHeader).expect(200)
const parsed = getAdminHomeNewsEndpoint.response.validate(res.body)
expect(parsed.value).toContainEqual(expect.objectContaining({ id: seeded.id, isActive: false }))
})
})
})
describe('POST /referrals/referrer-code', () => {
describe('When the referral code matches another customer link', () => {
it('Then sets the investor referrer link and returns the code', async () => {
const owner = await TestAuthHelper.createInvestor()
const ownerLink = await seedReferralLink({ customerId: owner.customerId })
const investor = await TestAuthHelper.createInvestor()
const res = await req().post(path).set('Cookie', investor.cookieHeader)
.send({ referralCode: ownerLink.code }).expect(201)
expect(res.body).toEqual({ referrerCode: ownerLink.code })
})
})
})
Convention fixtures
*.build.ts = factory pur (objet model-mock, aucune I/O) · *.seed.ts = insert via le
vrai repo prod (jamais de SQL brut). Les reads passent aussi par le repo prod.
🧰 Helpers dispo (tests/integration/helpers/)¶
| Helper | Rôle |
|---|---|
req() (app/test-app.ts) |
supertest sur l'app bootée 1×/session |
TestAuthHelper (auth/) |
.createInvestor() / .createAdmin() / .createNonAdminBackofficeUser() → { cookieHeader, … } |
DbHelper (db/) |
resetAll() (isolation), testDb() (Kysely typé) |
mswServer (providers/msw-server.ts) |
mocks réseau ; .use(...) par test, reset auto en beforeEach |
CustomerioEmailSpy (providers/) |
capture les emails sortants (OTP, reset token…) |
poll(fn, opts) (poll.ts) |
retry d'une condition async |
📦 Choix des libs¶
| Besoin | Choix | Pourquoi (alternative écartée) |
|---|---|---|
| Test runner | Vitest | Même runner que les unitaires, config partagée, watch + UI natifs. (Jest : pas d'un 2e runner.) |
| Boot de l'app | NestJS AppModule réel + Fastify | Vrais controllers/guards/pipes. (Test.createTestingModule : deadlock nestjs-pino.) |
| Requêtes HTTP | supertest (req()) |
Standard Nest, tape le serveur monté. |
| Base de données | Postgres réel (Docker) | Vrais joins/transactions/triggers/FK. (pg-mem/sqlite : divergence SQL.) |
| Migrations | Flyway | Même outil qu'en prod → schéma iso-prod. |
| Baseline schéma | snapshot pg_dump | Import ~1 s vs rejouer toutes les migrations. |
| Décorateurs | @swc/core + unplugin-swc | esbuild n'émet pas la metadata des décorateurs Nest. |
| Mock APIs externes | MSW | Interception réseau (Customer.io…) sans monkey-patch. |
| Cache | Redis réel (Docker) | Comportement TTL/flush réel ; pas de mock. |
| Secrets / env | Doppler (dev_integration_tests) |
Source unique, pas de .env sur disque. |
🧯 Gotchas & maintenance¶
À connaître
- Nouvelle
@Entity→ l'ajouter àhelpers/db/all-entities.ts(le loader TS de Vitest ne glob pas les entités). SinonNo metadata for "X"au boot.pnpm lintle vérifie. ⚠️ Devrait rester rare : on décommissionne TypeORM — une nouvelle entité est un signal à questionner. - Docker éteint →
global-setup.tssort avec un message FR explicite. - Pas loggué Doppler →
doppler secrets downloadéchoue ; fairedoppler login. - CI → tourne dans le job Integration tests (
api-validate.yml) via un service token Doppler.
📐 Conventions & lint¶
Règles d'agents IA — lisibles aussi par les devs
Ces fichiers vivent sous agents/ car ils servent de contexte aux assistants IA, mais ils sont
écrits pour être lus directement par l'équipe.
- Conventions tests d'intégration API — quand écrire un test d'intégration vs unitaire, architecture trois couches (descriptor → test → seed/build), routes via descriptors, auth, MSW, isolation, snapshot.
- Conventions de test communes — cadre Vitest partagé (API + packages), fake timers, mocking, AI-slop checklist.
pnpm lint exécute aussi scripts/lint-integration-test-rules.ts : pas d'appel DB brut dans les
tests, pas d'import de fixtures en prod, toute @Entity enregistrée.