Aller au contenu principal

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

MetriqueValeur
Suites de tests157
Tests unitaires et integration4,058
Tests E2E21
Couverture minimale80%

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

OutilFonction
JestRunner de tests, assertions, mocking
ts-jestSupport TypeScript pour Jest
SupertestAssertions HTTP pour les tests E2E
K6Tests de charge et benchmarks de performance
@nestjs/testingUtilitaires de test NestJS

Pyramide de Tests

                    /\
/ \
/ E2E \ (~10%) - Lent, couteux
/________\
/ \
/ Integration \ (~20%) - Vitesse moyenne
/________________\
/ \
/ Tests Unitaires \ (~70%) - Rapide, economique
/______________________\

Distribution

TypePourcentageTemps d'executionPerimetre
Unitaire~70%< 5 secondesFonction/classe unique
Integration~20%< 30 secondesModule/service
E2E~10%< 2 minutesFlux 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

MetriqueCibleCritique
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

TypeCouverture cible
Statements80%
Branches75%
Functions80%
Lines80%

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.