# 🧪 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: ['/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '/$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(); 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( ); 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( ); expect(screen.getByDisplayValue('Villa Moderne')).toBeInTheDocument(); }); it('devrait valider les champs obligatoires', async () => { render( ); 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( ); // 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( ); 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( ); expect(screen.getByText('Chantier 1')).toBeInTheDocument(); expect(screen.getByText('Chantier 2')).toBeInTheDocument(); }); it('devrait afficher un message si aucune donnée', () => { render( ); expect(screen.getByText(/aucune donnée disponible/i)).toBeInTheDocument(); }); it('devrait appeler onEdit lors du clic sur éditer', () => { const mockOnEdit = jest.fn(); render( ); 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(); 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(); 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