Introduction
En 2026, les pipelines CI/CD ne se contentent plus d'un simple npm test. Un vrai système de tests avancés intègre unitaires, d'intégration (avec mocks ou conteneurs), E2E (via Playwright), une matrix multi-versions Node.js, du caching intelligent (pnpm store), des thresholds de coverage stricts (>90%) et des artifacts pour rapports HTML. Cela réduit les merges défectueux de 70% selon les études GitHub.
Ce tutoriel avancé vous guide pas à pas pour un projet Node.js réel : une API mathématique simple, testée exhaustivement. Imaginez comme un convoyeur automobile : chaque étape valide une pièce avant assemblage. À la fin, votre repo GitHub déploiera uniquement du code blindé, prêt prod. Idéal pour équipes seniors gérant microservices critiques. (142 mots)
Prérequis
- Node.js 20+ et pnpm 9+
- Compte GitHub avec repo vide
- Connaissances avancées : Jest, Supertest, Playwright, Docker basics
- Git installé localement
- VS Code avec extensions YAML et TypeScript
Initialiser le projet avec pnpm
mkdir ci-cd-testing-advanced
cd ci-cd-testing-advanced
pnpm init
pnpm add -D typescript @types/node jest @types/jest ts-jest supertest @types/supertest playwright @playwright/test pnpm@latest
pnpm add express
pnpm tsx --versionCe script initialise un projet Node.js avec pnpm pour un caching optimal en CI. On installe TypeScript, Jest pour unitaires, Supertest pour intégration, Playwright pour E2E. Piège : utiliser pnpm au lieu de npm évite les doublons et accélère les installs CI de 50%.
Configurer package.json et tsconfig
{
"name": "ci-cd-testing-advanced",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"test:unit": "jest --coverage --passWithNoTests",
"test:integration": "jest --config jest.integration.config.js",
"test:e2e": "playwright test",
"test:ci": "pnpm run test:unit && pnpm run test:integration && pnpm run test:e2e",
"dev": "tsx watch src/index.ts"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"collectCoverageFrom": ["src/**/*.ts", "!src/**/*.d.ts"],
"coverageThreshold": {
"global": {
"branches": 90,
"functions": 90,
"lines": 90,
"statements": 90
}
}
}
}Package.json définit tous les scripts CI-ready avec coverage thresholds à 90% : échec si <90%. test:ci chain tout. Piège : collectCoverageFrom ignore les .d.ts pour metrics précises ; sans ça, coverage gonflé fausse les rapports.
Implémenter l'API et tests unitaires
export class MathService {
static add(a: number, b: number): number {
if (typeof a !== 'number' || typeof b !== 'number') throw new Error('Inputs must be numbers');
return a + b;
}
static divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
static fibonacci(n: number): number {
if (n < 0) throw new Error('Negative input');
if (n <= 1) return n;
let a = 0, b = 1;
for (let i = 2; i <= n; i++) {
const next = a + b;
a = b;
b = next;
}
return b;
}
}
export interface MathRequest { op: 'add' | 'divide' | 'fib'; a: number; b?: number; }MathService : cœur métier testable. Unitaires suivants valideront branches edge cases. Analogie : comme un circuit électrique, chaque chemin (if/loop) doit être 'allumé' par un test. Piège : sans validation types, tests int grisent les unitaires.
Tests unitaires Jest complets
import { MathService, MathRequest } from '../../src/math';
describe('MathService', () => {
describe('add', () => {
it('should add two numbers', () => expect(MathService.add(2, 3)).toBe(5));
it('should throw on non-numbers', () => {
expect(() => MathService.add('2' as any, 3)).toThrow('Inputs must be numbers');
});
});
describe('divide', () => {
it('should divide', () => expect(MathService.divide(10, 2)).toBe(5));
it('should throw on zero divisor', () => expect(() => MathService.divide(10, 0)).toThrow('Division by zero'));
});
describe('fibonacci', () => {
it.each([0, 1, 5, 10])( 'fib(%i) = %i', (n: number, expected: number) => {
expect(MathService.fibonacci(n)).toBe(expected);
});
it('throws on negative', () => expect(() => MathService.fibonacci(-1)).toThrow('Negative input'));
});
});Tests 100% coverage : table-driven pour fibo (efficace), edge cases prioritaires. --passWithNoTests en CI évite faux positifs. Piège : ignorer it.each ralentit ; ici, matrix-like pour 4 cas en 1 test.
Workflow GitHub Actions : Unit + Matrix
name: Unit Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run test:unit
env:
CI: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-${{ matrix.node-version }}
path: coverage/Premier workflow : matrix Node 18-22 teste compat multi-versions en parallèle (x3 vitesse). Caching pnpm + --frozen-lockfile = installs <10s. Artifacts préservent rapports même sur échecs. Piège : sans if: always(), artifacts perdus sur fail.
API Express + Tests d'intégration
import express from 'express';
import { MathService, MathRequest } from './math';
const app = express();
app.use(express.json());
app.post('/math', (req, res) => {
try {
const { op, a, b }: MathRequest = req.body;
let result;
switch (op) {
case 'add': result = MathService.add(a, b!); break;
case 'divide': result = MathService.divide(a, b!); break;
case 'fib': result = MathService.fibonacci(a); break;
default: return res.status(400).json({ error: 'Invalid op' });
}
res.json({ result });
} catch (e) {
res.status(400).json({ error: (e as Error).message });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server on ${port}`));API REST complète sur MathService. POST /math gère switch + errors. Tests int suivants simulent HTTP réel. Piège : sans express.json(), body parsing échoue ; toujours middleware first.
Tests d'intégration Supertest
import request from 'supertest';
import app from '../../src/index';
describe('API Integration', () => {
it('POST /math add success', async () => {
const res = await request(app).post('/math').send({ op: 'add', a: 2, b: 3 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ result: 5 });
});
it('POST /math divide error', async () => {
const res = await request(app).post('/math').send({ op: 'divide', a: 10, b: 0 });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Division by zero');
});
it('POST /math invalid op', async () => {
const res = await request(app).post('/math').send({ op: 'foo' as any, a: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid op');
});
});Supertest simule req HTTP sans serveur lancé (rapide). Couvre happy/edge paths. Ajoutez jest.integration.config.js pour isoler : { testMatch: '/integration//*.test.ts' }. Piège : oublier async/await crash les tests.
Workflow Intégration + E2E avec Docker
name: Integration & E2E
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm run test:integration
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- uses: microsoft/playwright-github-action@v1
- run: pnpm exec playwright install --with-deps
- run: pnpm run build
- run: pnpm run test:e2e
env: { PORT: 3000 }
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/Jobs parallèles : int sans Docker (rapide), E2E avec Playwright official action (browsers auto). Needs playwright.config.ts basique. Piège : sans --with-deps, browsers crash en CI headless.
Config Playwright E2E minimal
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});Config E2E : parallel safe, retries CI-only, HTML report artifact-ready. Lancez serveur dev pour local. Piège : forbidOnly bloque test.only() en CI, évitant skips accidentels.
Bonnes pratiques
- Matrix + Caching : Toujours tester 3 Node versions ; pnpm cache divise temps x4.
- Thresholds stricts : 90% min, fail-fast en PR.
- Artifacts always() : Rapports visibles même sur fails.
- Needs/depends_on : Chain jobs (unit → int → e2e) pour optim.
- Secrets env : DB creds en GitHub Secrets pour tests réels.
Erreurs courantes à éviter
- Oublier
frozen-lockfile: installs drift, builds instables. - Pas de coverage thresholds : merges low-quality code.
- E2E sans
baseURL: timeouts localhost. - Matrix sans strategy : jobs séquentiels, CI lente >10min.
Pour aller plus loin
Intégrez SonarQube pour code smells ou ArgoCD pour CD Kubernetes. Découvrez nos formations DevOps avancées Learni : CI/CD GitOps, observabilité full-stack.