Initial commit
This commit is contained in:
528
TESTING.md
Normal file
528
TESTING.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# 🧪 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
|
||||
|
||||
Reference in New Issue
Block a user