Aller au contenu

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.

🚀 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&nbsp;?}
    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 :

pnpm --filter @bricks/api test:integration:snapshot   # fresh DB → migrate → pg_dump ; review + commit

🗂️ 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). Sinon No metadata for "X" au boot. pnpm lint le vérifie. ⚠️ Devrait rester rare : on décommissionne TypeORM — une nouvelle entité est un signal à questionner.
  • Docker éteintglobal-setup.ts sort avec un message FR explicite.
  • Pas loggué Dopplerdoppler secrets download échoue ; faire doppler 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.