Files
btpxpress-frontend/TESTING.md
2025-10-13 05:29:32 +02:00

529 lines
12 KiB
Markdown

# 🧪 TESTS - BTPXPRESS FRONTEND
## 📋 Table des matières
- [Vue d'ensemble](#vue-densemble)
- [Configuration](#configuration)
- [Tests unitaires](#tests-unitaires)
- [Tests de composants](#tests-de-composants)
- [Tests d'intégration](#tests-dintégration)
- [Tests E2E](#tests-e2e)
- [Couverture de code](#couverture-de-code)
- [Bonnes pratiques](#bonnes-pratiques)
---
## 🎯 Vue d'ensemble
### **Framework de tests**
- **Jest** : Framework de tests
- **React Testing Library** : Tests de composants React
- **MSW (Mock Service Worker)** : Mock des API
- **Playwright** : Tests end-to-end (optionnel)
### **Objectifs de couverture**
| Type | Objectif |
|------|----------|
| **Ligne** | 80% |
| **Branche** | 70% |
| **Fonction** | 85% |
---
## ⚙️ Configuration
### **jest.config.js**
```javascript
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'services/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
],
}
module.exports = createJestConfig(customJestConfig)
```
### **jest.setup.js**
```javascript
import '@testing-library/jest-dom'
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
```
### **package.json**
```json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"@testing-library/react": "^14.0.0",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/user-event": "^14.5.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0"
}
}
```
---
## 🔬 Tests unitaires
### **Test d'un service**
**Fichier** : `services/api/__tests__/chantierService.test.ts`
```typescript
import { chantierService } from '../chantierService';
import apiClient from '../apiClient';
jest.mock('../apiClient');
describe('chantierService', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('getAll', () => {
it('devrait retourner la liste des chantiers', async () => {
// Arrange
const mockChantiers = [
{ id: '1', nom: 'Chantier 1' },
{ id: '2', nom: 'Chantier 2' },
];
(apiClient.get as jest.Mock).mockResolvedValue({ data: mockChantiers });
// Act
const result = await chantierService.getAll();
// Assert
expect(apiClient.get).toHaveBeenCalledWith('/chantiers');
expect(result).toEqual(mockChantiers);
});
it('devrait gérer les erreurs', async () => {
// Arrange
const error = new Error('Network error');
(apiClient.get as jest.Mock).mockRejectedValue(error);
// Act & Assert
await expect(chantierService.getAll()).rejects.toThrow('Network error');
});
});
describe('create', () => {
it('devrait créer un chantier', async () => {
// Arrange
const newChantier = { nom: 'Nouveau Chantier', code: 'NC-001' };
const createdChantier = { id: '1', ...newChantier };
(apiClient.post as jest.Mock).mockResolvedValue({ data: createdChantier });
// Act
const result = await chantierService.create(newChantier);
// Assert
expect(apiClient.post).toHaveBeenCalledWith('/chantiers', newChantier);
expect(result).toEqual(createdChantier);
});
});
});
```
---
## 🧩 Tests de composants
### **Test d'un composant simple**
**Fichier** : `components/common/__tests__/LoadingSpinner.test.tsx`
```typescript
import { render, screen } from '@testing-library/react';
import { LoadingSpinner } from '../LoadingSpinner';
describe('LoadingSpinner', () => {
it('devrait afficher le spinner', () => {
render(<LoadingSpinner />);
const spinner = screen.getByRole('progressbar');
expect(spinner).toBeInTheDocument();
});
});
```
### **Test d'un formulaire**
**Fichier** : `components/forms/__tests__/ChantierForm.test.tsx`
```typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChantierForm } from '../ChantierForm';
describe('ChantierForm', () => {
const mockOnSubmit = jest.fn();
const mockOnCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('devrait afficher le formulaire vide', () => {
render(
<ChantierForm
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
expect(screen.getByLabelText(/nom/i)).toBeInTheDocument();
expect(screen.getByLabelText(/statut/i)).toBeInTheDocument();
});
it('devrait afficher les données du chantier en mode édition', () => {
const chantier = {
id: '1',
nom: 'Villa Moderne',
code: 'VM-001',
statut: 'EN_COURS',
};
render(
<ChantierForm
chantier={chantier}
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
expect(screen.getByDisplayValue('Villa Moderne')).toBeInTheDocument();
});
it('devrait valider les champs obligatoires', async () => {
render(
<ChantierForm
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
const submitButton = screen.getByRole('button', { name: /enregistrer/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/le nom est obligatoire/i)).toBeInTheDocument();
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('devrait soumettre le formulaire avec des données valides', async () => {
const user = userEvent.setup();
render(
<ChantierForm
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
// Remplir le formulaire
const nomInput = screen.getByLabelText(/nom/i);
await user.type(nomInput, 'Villa Moderne');
// Soumettre
const submitButton = screen.getByRole('button', { name: /enregistrer/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
nom: 'Villa Moderne',
})
);
});
});
it('devrait appeler onCancel lors du clic sur Annuler', async () => {
const user = userEvent.setup();
render(
<ChantierForm
onSubmit={mockOnSubmit}
onCancel={mockOnCancel}
/>
);
const cancelButton = screen.getByRole('button', { name: /annuler/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
});
```
### **Test d'un tableau**
**Fichier** : `components/tables/__tests__/DataTableWrapper.test.tsx`
```typescript
import { render, screen, fireEvent } from '@testing-library/react';
import { DataTableWrapper } from '../DataTableWrapper';
describe('DataTableWrapper', () => {
const mockData = [
{ id: '1', nom: 'Chantier 1', code: 'CH-001' },
{ id: '2', nom: 'Chantier 2', code: 'CH-002' },
];
const columns = [
{ field: 'nom', header: 'Nom', sortable: true },
{ field: 'code', header: 'Code', sortable: true },
];
it('devrait afficher les données', () => {
render(
<DataTableWrapper
data={mockData}
columns={columns}
/>
);
expect(screen.getByText('Chantier 1')).toBeInTheDocument();
expect(screen.getByText('Chantier 2')).toBeInTheDocument();
});
it('devrait afficher un message si aucune donnée', () => {
render(
<DataTableWrapper
data={[]}
columns={columns}
/>
);
expect(screen.getByText(/aucune donnée disponible/i)).toBeInTheDocument();
});
it('devrait appeler onEdit lors du clic sur éditer', () => {
const mockOnEdit = jest.fn();
render(
<DataTableWrapper
data={mockData}
columns={columns}
onEdit={mockOnEdit}
/>
);
const editButtons = screen.getAllByRole('button', { name: /edit/i });
fireEvent.click(editButtons[0]);
expect(mockOnEdit).toHaveBeenCalledWith(mockData[0]);
});
});
```
---
## 🔗 Tests d'intégration
### **Test d'une page**
**Fichier** : `app/(auth)/chantiers/__tests__/page.test.tsx`
```typescript
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import ChantiersPage from '../page';
const server = setupServer(
rest.get('/api/v1/chantiers', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', nom: 'Chantier 1', statut: 'EN_COURS' },
{ id: '2', nom: 'Chantier 2', statut: 'PLANIFIE' },
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('ChantiersPage', () => {
it('devrait charger et afficher les chantiers', async () => {
render(<ChantiersPage />);
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Chantier 1')).toBeInTheDocument();
expect(screen.getByText('Chantier 2')).toBeInTheDocument();
});
});
it('devrait afficher une erreur en cas d\'échec', async () => {
server.use(
rest.get('/api/v1/chantiers', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<ChantiersPage />);
await waitFor(() => {
expect(screen.getByText(/erreur/i)).toBeInTheDocument();
});
});
});
```
---
## 🎭 Tests E2E
### **Playwright (optionnel)**
**Installation** :
```bash
npm install -D @playwright/test
npx playwright install
```
**Fichier** : `e2e/chantiers.spec.ts`
```typescript
import { test, expect } from '@playwright/test';
test.describe('Chantiers', () => {
test('devrait afficher la liste des chantiers', async ({ page }) => {
await page.goto('http://localhost:3000/chantiers');
await expect(page.locator('h1')).toContainText('Chantiers');
await expect(page.locator('table')).toBeVisible();
});
test('devrait créer un nouveau chantier', async ({ page }) => {
await page.goto('http://localhost:3000/chantiers/nouveau');
await page.fill('input[name="nom"]', 'Villa Test');
await page.fill('input[name="code"]', 'VT-001');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/chantiers\/\w+/);
await expect(page.locator('text=Villa Test')).toBeVisible();
});
});
```
---
## 📊 Couverture de code
### **Générer le rapport**
```bash
npm run test:coverage
```
### **Rapport HTML**
Le rapport est généré dans `coverage/lcov-report/index.html`
```bash
# Ouvrir le rapport
open coverage/lcov-report/index.html
```
---
## ✅ Bonnes pratiques
### **1. Nommage des tests**
```typescript
// ❌ Mauvais
it('test 1', () => { });
// ✅ Bon
it('devrait afficher le formulaire vide', () => { });
```
### **2. Pattern AAA**
```typescript
it('devrait créer un chantier', async () => {
// Arrange
const data = { nom: 'Test' };
// Act
const result = await service.create(data);
// Assert
expect(result).toBeDefined();
});
```
### **3. Tests isolés**
```typescript
beforeEach(() => {
jest.clearAllMocks();
});
```
### **4. Utiliser screen queries**
```typescript
// ✅ Bon
const button = screen.getByRole('button', { name: /enregistrer/i });
// ❌ Éviter
const button = container.querySelector('button');
```
---
**Dernière mise à jour** : 2025-09-30
**Version** : 1.0
**Auteur** : Équipe BTPXpress