Tests¶
L'écosystème de test JavaScript et TypeScript est riche et mature. Vitest s'est impose comme le standard moderne pour les tests unitaires et d'intégration, tandis que Playwright domine les tests end-to-end. Ce chapitre couvre les outils, les patterns et les exemples complets.
Comparatif des outils¶
| Outil | Type | Runtime | Compatibilité Jest | Vitesse | Points forts |
|---|---|---|---|---|---|
| Vitest | Unit / Int | Vite | ~100% | Très rapide | Hot reload, TypeScript natif, ui mode |
| Jest | Unit / Int | Babel/SWC | Référence | Moyenne | Maturité, ecosystem, snapshots |
| Playwright | E2E | Node.js | — | Parallel | Multi-browser, trace viewer, CI-ready |
| Testing Library | UI | JSDOM/real | Jest/Vitest | Variable | Tests du point de vue utilisateur |
| Cypress | E2E | Electron | — | Moyenne | DX visuel, time-travel debugging |
Recommandation 2024
Pour les nouveaux projets : Vitest pour les tests unitaires/intégration + Playwright pour les tests e2e. Jest reste pertinent dans les projets existants, mais Vitest offre une compatibilité API quasi complète avec de meilleures performances.
Configuration Vitest¶
// vitest.config.ts — Configuration Vitest avec coverage v8
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Environnement pour les tests DOM (React, Vue)
environment: 'node', // 'jsdom' pour les tests de composants UI
// Globals : describe, it, expect disponibles sans import
globals: true,
// Coverage avec le provider v8 (natif Node.js)
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/server.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
},
});
// package.json — scripts de test
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"@vitest/ui": "^2.0.0",
"vitest": "^2.0.0"
}
}
Tests unitaires sur l'API Fastify¶
Les tests suivants portent sur les routes CRUD définies dans le chapitre 03.
// src/routes/items.test.ts — Tests unitaires avec Vitest et mocking Prisma
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { itemsPlugin } from './items.js';
// Mocking du module Prisma — remplace le client reel par un mock
vi.mock('../db.js', () => ({
prisma: {
item: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// Import apres le mock pour obtenir les fonctions espionnees
import { prisma } from '../db.js';
// Utilitaire : cree une instance Fastify fraiche pour chaque test
async function buildApp() {
const app = Fastify();
await app.register(itemsPlugin);
await app.ready();
return app;
}
describe('GET /items', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('retourne une liste vide si aucun item', async () => {
vi.mocked(prisma.item.findMany).mockResolvedValue([]);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/items' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
});
it('retourne les items existants', async () => {
const mockItems = [
{ id: 1, name: 'Item A', done: false, createdAt: new Date(), updatedAt: new Date() },
{ id: 2, name: 'Item B', done: true, createdAt: new Date(), updatedAt: new Date() },
];
vi.mocked(prisma.item.findMany).mockResolvedValue(mockItems);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/items' });
expect(res.statusCode).toBe(200);
expect(res.json()).toHaveLength(2);
expect(res.json()[0].name).toBe('Item A');
});
});
describe('POST /items', () => {
beforeEach(() => vi.clearAllMocks());
it('cree un item valide', async () => {
const newItem = { id: 3, name: 'Nouveau', done: false, createdAt: new Date(), updatedAt: new Date() };
vi.mocked(prisma.item.create).mockResolvedValue(newItem);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/items',
payload: { name: 'Nouveau' },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Nouveau');
expect(prisma.item.create).toHaveBeenCalledWith({
data: { name: 'Nouveau', done: false },
});
});
it('rejette un item sans nom', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/items',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
expect(prisma.item.create).not.toHaveBeenCalled();
});
});
describe('PUT /items/:id', () => {
beforeEach(() => vi.clearAllMocks());
it('met a jour un item existant', async () => {
const updated = { id: 1, name: 'Modifie', done: true, createdAt: new Date(), updatedAt: new Date() };
vi.mocked(prisma.item.update).mockResolvedValue(updated);
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/items/1',
payload: { done: true },
});
expect(res.statusCode).toBe(200);
expect(res.json().done).toBe(true);
});
it('retourne 404 si item inexistant', async () => {
vi.mocked(prisma.item.update).mockRejectedValue(new Error('Not found'));
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/items/999',
payload: { done: true },
});
expect(res.statusCode).toBe(404);
});
});
Mocking avec vi.mock¶
// src/services/mailer.test.ts — Patterns avances de mocking
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock d'un module externe entier
vi.mock('nodemailer', () => ({
default: {
createTransport: vi.fn().mockReturnValue({
sendMail: vi.fn().mockResolvedValue({ messageId: 'test-id' }),
}),
},
}));
// Mock partiel : ne remplace que certaines fonctions du module
vi.mock('../utils/date.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../utils/date.js')>();
return {
...original, // conserve les autres exports
now: vi.fn().mockReturnValue(new Date('2024-01-15')),
};
});
// Spy sur une methode sans la remplacer
describe('Spies', () => {
it('espionne console.log sans le remplacer', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
console.log('test');
expect(spy).toHaveBeenCalledWith('test');
spy.mockRestore();
});
});
Tests de composants React avec Testing Library¶
// src/components/ItemForm.test.tsx — Testing Library + Vitest
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ItemForm from './ItemForm.js';
// Mock du routeur Next.js
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
refresh: vi.fn(),
})),
}));
// Mock de fetch global
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('ItemForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('affiche le formulaire avec le champ nom et le bouton', () => {
render(<ItemForm />);
// Testing Library : cherche par le texte visible a l'utilisateur
expect(screen.getByPlaceholderText("Nom de l'item")).toBeInTheDocument();
expect(screen.getByRole('button', { name: /creer/i })).toBeInTheDocument();
});
it('soumet le formulaire et redirige apres succes', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'Test', done: false }),
} as Response);
const user = userEvent.setup();
render(<ItemForm />);
const input = screen.getByPlaceholderText("Nom de l'item");
await user.type(input, 'Mon nouvel item');
await user.click(screen.getByRole('button', { name: /creer/i }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Mon nouvel item' }),
});
});
});
it('affiche une erreur si le nom est vide', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: async () => ({
error: { fieldErrors: { name: ['Le nom est requis'] } },
}),
} as unknown as Response);
const user = userEvent.setup();
render(<ItemForm />);
await user.click(screen.getByRole('button', { name: /creer/i }));
// Le bouton submit HTML valide empechera la soumission si le champ est vide
// On verifie que fetch n'est pas appele avec un nom vide
expect(mockFetch).not.toHaveBeenCalled();
});
});
Tests end-to-end avec Playwright¶
// tests/items.spec.ts — Playwright E2E
import { test, expect } from '@playwright/test';
// Configuration : playwright.config.ts
// import { defineConfig } from '@playwright/test';
// export default defineConfig({
// testDir: './tests',
// use: { baseURL: 'http://localhost:3000' },
// webServer: { command: 'npm run dev', port: 3000 },
// });
test.describe('Gestion des items', () => {
test('affiche la liste vide au demarrage', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Items' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Nouvel item' })).toBeVisible();
});
test('cree un item et le voir dans la liste', async ({ page }) => {
await page.goto('/items/new');
// Remplit et soumet le formulaire
await page.getByPlaceholder("Nom de l'item").fill('Item de test E2E');
await page.getByRole('button', { name: /creer/i }).click();
// Attend la redirection vers la liste
await expect(page).toHaveURL('/');
// Verifie que l'item apparait dans la liste
await expect(page.getByText('Item de test E2E')).toBeVisible();
});
test('navigue vers le detail d un item', async ({ page }) => {
await page.goto('/');
// Clique sur le premier item de la liste
const firstItem = page.getByRole('listitem').first().getByRole('link');
const itemName = await firstItem.textContent();
await firstItem.click();
// Verifie que la page de detail est chargee
await expect(page.getByText(itemName!)).toBeVisible();
});
});
Couverture de code¶
# Lancer les tests avec couverture
npm run test:coverage
# Rapport HTML genere dans coverage/index.html
# Rapport console :
# -------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# -------------|---------|----------|---------|---------|
# routes/ | | | | |
# items.ts | 92.30 | 87.50 | 100 | 92.30 |
# schemas.ts | 100 | 100 | 100 | 100 |
# db.ts | 100 | 100 | 100 | 100 |
# -------------|---------|----------|---------|---------|
Couverture n'est pas qualité
Un taux de couverture élevé ne garantit pas la qualité des tests. Des tests qui exécutent le code sans vérifier les cas limites (valeurs nulles, erreurs réseau, IDs invalides) peuvent atteindre 100% sans détecter les vrais bugs. Privilegier des assertions significatives sur des métriques de couverture.