Strategie de Tests
Strategie de tests complete pour MyTelevision API couvrant les tests unitaires, d'integration, E2E et de performance.
Vue d'ensemble
Statistiques actuelles
| Metrique | Valeur |
|---|---|
| Suites de tests | 157 |
| Tests unitaires et integration | 4,058 |
| Tests E2E | 21 |
| Couverture minimale | 80% |
Philosophie de Test
- Tester le comportement, pas l'implementation -- Se concentrer sur ce que le code fait, pas comment
- Feedback rapide -- Les tests unitaires s'executent en millisecondes
- Isolation -- Chaque test est independant
- Deterministe -- La meme entree produit toujours la meme sortie
- Lisible -- Les tests servent de documentation
Outils et Frameworks
| Outil | Fonction |
|---|---|
| Jest | Runner de tests, assertions, mocking |
| ts-jest | Support TypeScript pour Jest |
| Supertest | Assertions HTTP pour les tests E2E |
| K6 | Tests de charge et benchmarks de performance |
| @nestjs/testing | Utilitaires de test NestJS |
Pyramide de Tests
/\
/ \
/ E2E \ (~10%) - Lent, couteux
/________\
/ \
/ Integration \ (~20%) - Vitesse moyenne
/________________\
/ \
/ Tests Unitaires \ (~70%) - Rapide, economique
/______________________\
Distribution
| Type | Pourcentage | Temps d'execution | Perimetre |
|---|---|---|---|
| Unitaire | ~70% | < 5 secondes | Fonction/classe unique |
| Integration | ~20% | < 30 secondes | Module/service |
| E2E | ~10% | < 2 minutes | Flux API complet |
Tests Unitaires
Emplacement
src/
├── application/
│ └── services/
│ └── movies/
│ ├── movies.service.ts
│ └── movies.service.spec.ts # Test unitaire
Commandes
# Executer tous les tests unitaires
npm run test
# Mode watch
npm run test:watch
# Fichier specifique
npm run test -- --testPathPattern=movies.service
# Avec couverture
npm run test:cov
Structure type
// movies.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { MoviesService } from './movies.service';
import { PrismaService } from '@infrastructure/database/prisma';
import { RedisService } from '@infrastructure/cache';
describe('MoviesService', () => {
let service: MoviesService;
let prisma: jest.Mocked<PrismaService>;
let redis: jest.Mocked<RedisService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MoviesService,
{
provide: PrismaService,
useValue: {
movie: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
},
{
provide: RedisService,
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
},
},
],
}).compile();
service = module.get<MoviesService>(MoviesService);
prisma = module.get(PrismaService);
redis = module.get(RedisService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findOne', () => {
it('should return cached movie if available', async () => {
const movie = { id: '1', title: 'Test Movie' };
redis.get.mockResolvedValue(JSON.stringify(movie));
const result = await service.findOne('1');
expect(result).toEqual(movie);
expect(redis.get).toHaveBeenCalledWith('movie:1');
expect(prisma.movie.findUnique).not.toHaveBeenCalled();
});
it('should query database on cache miss', async () => {
const movie = { id: '1', title: 'Test Movie' };
redis.get.mockResolvedValue(null);
prisma.movie.findUnique.mockResolvedValue(movie);
const result = await service.findOne('1');
expect(result).toEqual(movie);
expect(prisma.movie.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
expect(redis.set).toHaveBeenCalled();
});
it('should return null for non-existent movie', async () => {
redis.get.mockResolvedValue(null);
prisma.movie.findUnique.mockResolvedValue(null);
const result = await service.findOne('non-existent');
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create movie and invalidate cache', async () => {
const dto = { title: 'New Movie', overview: 'Description' };
const created = { id: '2', ...dto };
prisma.movie.create.mockResolvedValue(created);
const result = await service.create(dto);
expect(result).toEqual(created);
expect(redis.del).toHaveBeenCalledWith('movies:list:*');
});
});
});
Patterns de Mocking
Mock Prisma
const mockPrismaService = {
movie: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
$transaction: jest.fn((fn) => fn(mockPrismaService)),
};
Mock Redis
const mockRedisService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
keys: jest.fn(),
setex: jest.fn(),
};
Mock Services Externes
const mockTmdbService = {
getMovieDetails: jest.fn(),
searchMovies: jest.fn(),
getMovieImages: jest.fn(),
};
Tests d'Integration
Emplacement
test/
├── integration/
│ └── movies/
│ └── movies.integration.spec.ts
Commandes
# Demarrer la base de test
docker-compose up -d postgres-test
# Executer les tests d'integration
npm run test:integration
# Test specifique
npm run test:integration -- --testPathPattern=movies
Structure type
// test/integration/movies/movies.integration.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '@/app.module';
import { PrismaService } from '@infrastructure/database/prisma';
describe('Movies Integration', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
// Nettoyer la base avant chaque test
await prisma.movie.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
describe('Movie CRUD', () => {
it('should create and retrieve a movie', async () => {
const created = await prisma.movie.create({
data: {
title: 'Integration Test Movie',
overview: 'Test overview',
status: 'PUBLISHED',
},
});
const found = await prisma.movie.findUnique({
where: { id: created.id },
});
expect(found).toBeDefined();
expect(found.title).toBe('Integration Test Movie');
});
it('should support transactions', async () => {
const result = await prisma.$transaction(async (tx) => {
const movie = await tx.movie.create({
data: { title: 'TX Movie', status: 'DRAFT' },
});
await tx.movie.update({
where: { id: movie.id },
data: { status: 'PUBLISHED' },
});
return tx.movie.findUnique({ where: { id: movie.id } });
});
expect(result.status).toBe('PUBLISHED');
});
});
});
Tests End-to-End (E2E)
Emplacement
test/
├── e2e/
│ ├── auth.e2e-spec.ts
│ ├── movies.e2e-spec.ts
│ └── ...
├── helpers/
│ ├── auth.ts
│ └── setup.ts
├── jest-e2e.json
Commandes
# Demarrer tous les services
docker-compose up -d
# Executer les tests E2E
npm run test:e2e
# Test E2E specifique
npm run test:e2e -- --testPathPattern=auth
Configuration
test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/../src/$1",
"^@application/(.*)$": "<rootDir>/../src/application/$1",
"^@infrastructure/(.*)$": "<rootDir>/../src/infrastructure/$1",
"^@presentation/(.*)$": "<rootDir>/../src/presentation/$1",
"^@shared/(.*)$": "<rootDir>/../src/shared/$1"
},
"setupFilesAfterEnv": ["<rootDir>/helpers/setup.ts"],
"testTimeout": 30000
}
Structure type
// test/e2e/movies.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '@/app.module';
import { PrismaService } from '@infrastructure/database/prisma';
import { createTestUser, getAuthToken } from '../helpers/auth';
describe('Movies API (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
prisma = app.get(PrismaService);
const user = await createTestUser(prisma);
authToken = await getAuthToken(app, user);
});
afterAll(async () => {
await prisma.movie.deleteMany();
await prisma.user.deleteMany();
await app.close();
});
describe('GET /api/v2/movies', () => {
it('should return paginated published movies', () => {
return request(app.getHttpServer())
.get('/api/v2/movies')
.expect(200)
.expect((res) => {
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.meta.total).toBeDefined();
});
});
it('should support pagination', () => {
return request(app.getHttpServer())
.get('/api/v2/movies?page=1&limit=1')
.expect(200)
.expect((res) => {
expect(res.body.data).toHaveLength(1);
expect(res.body.meta.page).toBe(1);
});
});
});
describe('POST /api/v2/admin/movies (authenticated)', () => {
it('should create movie with valid token', () => {
return request(app.getHttpServer())
.post('/api/v2/admin/movies')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'New Movie', overview: 'Description' })
.expect(201)
.expect((res) => {
expect(res.body.title).toBe('New Movie');
expect(res.body.id).toBeDefined();
});
});
it('should return 401 without token', () => {
return request(app.getHttpServer())
.post('/api/v2/admin/movies')
.send({ title: 'New Movie' })
.expect(401);
});
});
});
Helpers de Test
test/helpers/auth.ts
import { INestApplication } from '@nestjs/common';
import { PrismaService } from '@infrastructure/database/prisma';
import * as bcrypt from 'bcrypt';
import * as request from 'supertest';
export async function createTestUser(prisma: PrismaService) {
return prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
password: await bcrypt.hash('TestPassword123!', 10),
role: 'ADMIN',
},
});
}
export async function getAuthToken(
app: INestApplication,
user: { email: string },
): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v2/auth/login')
.send({ email: user.email, password: 'TestPassword123!' });
return response.body.accessToken;
}
Factories de Donnees de Test
test/factories/movie.factory.ts
import { Movie } from '@prisma/client';
export function createMockMovie(overrides: Partial<Movie> = {}): Movie {
return {
id: 'test-movie-id',
title: 'Test Movie',
overview: 'Test overview',
status: 'PUBLISHED',
accessType: 'FREE',
releaseDate: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
Tests de Performance
K6 -- Tests de Charge
test/
├── performance/
│ ├── k6-load-test.js
│ └── results/
Commandes
# Test smoke (charge minimale)
npm run test:load:smoke
# Test de charge (trafic normal)
npm run test:load
# Test de stress (charge elevee)
npm run test:load:stress
# Test spike (trafic soudain)
npm run test:load:spike
Script K6
test/performance/k6-load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const errorRate = new Rate('errors');
const scenarios = {
smoke: {
executor: 'constant-vus',
vus: 1,
duration: '1m',
},
load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
},
stress: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 0 },
],
},
spike: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '10s', target: 100 },
{ duration: '1m', target: 100 },
{ duration: '10s', target: 500 },
{ duration: '3m', target: 500 },
{ duration: '10s', target: 100 },
{ duration: '3m', target: 100 },
{ duration: '10s', target: 0 },
],
},
};
export const options = {
scenarios: {
[__ENV.SCENARIO || 'smoke']: scenarios[__ENV.SCENARIO || 'smoke'],
},
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
errors: ['rate<0.01'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
// Health check
let res = http.get(`${BASE_URL}/api/v2/health`);
check(res, {
'health check status is 200': (r) => r.status === 200,
});
// Get movies list
res = http.get(`${BASE_URL}/api/v2/movies?page=1&limit=10`);
check(res, {
'movies list status is 200': (r) => r.status === 200,
'movies list has data': (r) => JSON.parse(r.body).data !== undefined,
});
errorRate.add(res.status !== 200);
sleep(1);
}
Seuils de Performance
| Metrique | Cible | Critique |
|---|---|---|
| p95 Temps de reponse | < 500ms | < 1000ms |
| p99 Temps de reponse | < 1000ms | < 2000ms |
| Taux d'erreur | < 1% | < 5% |
| Debit | > 100 rps | > 50 rps |
Couverture de Code
Exigences minimales
| Type | Couverture cible |
|---|---|
| Statements | 80% |
| Branches | 75% |
| Functions | 80% |
| Lines | 80% |
Configuration
package.json (extrait)
{
"jest": {
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"coverageThreshold": {
"global": {
"branches": 75,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Rapport de couverture
# Generer le rapport
npm run test:cov
# Ouvrir le rapport HTML
open coverage/lcov-report/index.html
Integration CI
GitHub Actions
.github/workflows/ci.yml (extrait)
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx prisma generate
- run: npm run test:cov
- name: Check coverage threshold
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage is below 80%"
exit 1
fi
Hooks Pre-commit
.husky/pre-commit
npm run test -- --passWithNoTests --bail
Bonnes Pratiques
A faire
- Ecrire les tests d'abord (TDD) quand possible
- Tester les cas limites et les conditions d'erreur
- Utiliser des noms de tests descriptifs
- Garder les tests independants
- Nettoyer les donnees de test
- Mocker les dependances externes
- Utiliser des factories pour les donnees de test
A ne pas faire
- Tester les details d'implementation
- Utiliser des IDs codes en dur
- Partager l'etat entre les tests
- Ignorer les tests instables (flaky)
- Sauter les tests de gestion d'erreur
- Sur-mocker (tester les vraies integrations parfois)
Convention de Nommage
describe('MoviesService', () => {
describe('findOne', () => {
it('should return movie when found', () => {});
it('should return null when not found', () => {});
it('should throw when database error', () => {});
});
});
Pattern AAA
Utiliser toujours le pattern Arrange, Act, Assert pour structurer chaque test.