Files
btpxpress-frontend/TESTING.md

12 KiB
Executable File

🧪 TESTS - BTPXPRESS FRONTEND

📋 Table des matières


🎯 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

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

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

{
  "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

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

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

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

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

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 :

npm install -D @playwright/test
npx playwright install

Fichier : e2e/chantiers.spec.ts

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

npm run test:coverage

Rapport HTML

Le rapport est généré dans coverage/lcov-report/index.html

# Ouvrir le rapport
open coverage/lcov-report/index.html

Bonnes pratiques

1. Nommage des tests

// ❌ Mauvais
it('test 1', () => { });

// ✅ Bon
it('devrait afficher le formulaire vide', () => { });

2. Pattern AAA

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

beforeEach(() => {
  jest.clearAllMocks();
});

4. Utiliser screen queries

// ✅ 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