Retour au blog

Tests Automatisés dans le Front-end : Guide Complet avec Vitest et Playwright

Salut HaWkers, les tests automatisés sont l'une des compétences les plus valorisées pour les développeurs front-end en 2025. Avec des applications de plus en plus complexes, garantir la qualité sans tests manuels exhaustifs est devenu impossible.

Avez-vous déjà lancé une feature en pensant que tout était bon, seulement pour découvrir qu'elle a cassé quelque chose dans une autre partie de l'application ? Résolvons cela définitivement.

Pourquoi Tester le Front-end en 2025

L'écosystème de tests JavaScript a considérablement mûri. Les outils modernes comme Vitest et Playwright ont rendu les tests plus rapides et agréables à écrire.

Le Coût de Ne Pas Tester

Statistiques du marché :

  • Les bugs en production coûtent 10x plus cher à corriger qu'en développement
  • 40% du temps de développement est passé à corriger des bugs
  • Les entreprises avec une bonne couverture de tests lancent des features 2x plus vite
  • Les code reviews sont 50% plus rapides quand il y a des tests

La pyramide des tests :

Type Quantité Vitesse Coût
Unitaires Beaucoup Très rapide Bas
Intégration Modéré Rapide Moyen
E2E Peu Lent Élevé

Configurer Vitest

Vitest est devenu le standard pour les tests unitaires et d'intégration dans les projets JavaScript modernes. Il est compatible avec l'API de Jest mais significativement plus rapide.

Setup Initial

# Installer Vitest et dépendances
npm install -D vitest @vitest/coverage-v8

# Pour React
npm install -D @testing-library/react @testing-library/jest-dom jsdom

# Pour Vue
npm install -D @vue/test-utils @testing-library/vue jsdom

Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Nettoie le DOM après chaque test
afterEach(() => {
  cleanup();
});

Tests Unitaires avec Vitest

Tester des Fonctions Utilitaires

// src/utils/formatters.ts
export function formatCurrency(value: number, locale = 'fr-FR'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: locale === 'fr-FR' ? 'EUR' : 'USD',
  }).format(value);
}

export function formatDate(date: Date | string, locale = 'fr-FR'): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  return new Intl.DateTimeFormat(locale, {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  }).format(d);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .trim();
}
// src/utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, formatDate, slugify } from './formatters';

describe('formatCurrency', () => {
  it('formate les valeurs en Euro français par défaut', () => {
    expect(formatCurrency(1234.56)).toBe('1 234,56 €');
  });

  it('formate les valeurs en Dollar américain', () => {
    expect(formatCurrency(1234.56, 'en-US')).toBe('$1,234.56');
  });

  it('formate les valeurs négatives correctement', () => {
    expect(formatCurrency(-500)).toBe('-500,00 €');
  });

  it('formate zéro correctement', () => {
    expect(formatCurrency(0)).toBe('0,00 €');
  });
});

describe('formatDate', () => {
  it('formate un objet Date en fr-FR', () => {
    const date = new Date('2025-11-22');
    expect(formatDate(date)).toBe('22/11/2025');
  });

  it('formate une string ISO en fr-FR', () => {
    expect(formatDate('2025-11-22')).toBe('22/11/2025');
  });

  it('formate en en-US quand spécifié', () => {
    expect(formatDate('2025-11-22', 'en-US')).toBe('11/22/2025');
  });
});

describe('slugify', () => {
  it('convertit un texte simple en slug', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('supprime les accents', () => {
    expect(slugify('Programmation en Français')).toBe('programmation-en-francais');
  });

  it('supprime les caractères spéciaux', () => {
    expect(slugify('Test@#$%String!')).toBe('teststring');
  });

  it('normalise les espaces et tirets multiples', () => {
    expect(slugify('Multiple   Spaces---Here')).toBe('multiple-spaces-here');
  });
});

Tester des Composants React

// src/components/Button.tsx
import { ButtonHTMLAttributes, ReactNode } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  isLoading?: boolean;
  children: ReactNode;
}

export function Button({
  variant = 'primary',
  isLoading = false,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const variants = {
    primary: 'bg-blue-500 hover:bg-blue-600 text-white',
    secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
    danger: 'bg-red-500 hover:bg-red-600 text-white',
  };

  return (
    <button
      className={`px-4 py-2 rounded font-medium transition-colors ${variants[variant]} ${
        (disabled || isLoading) && 'opacity-50 cursor-not-allowed'
      }`}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? (
        <span className="flex items-center gap-2">
          <span className="animate-spin">⏳</span>
          Chargement...
        </span>
      ) : (
        children
      )}
    </button>
  );
}
// src/components/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('rend le texte des children', () => {
    render(<Button>Cliquez-moi</Button>);
    expect(screen.getByText('Cliquez-moi')).toBeInTheDocument();
  });

  it('appelle onClick quand cliqué', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Cliquez-moi</Button>);

    fireEvent.click(screen.getByText('Cliquez-moi'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("n'appelle pas onClick quand disabled", () => {
    const handleClick = vi.fn();
    render(
      <Button onClick={handleClick} disabled>
        Cliquez-moi
      </Button>
    );

    fireEvent.click(screen.getByText('Cliquez-moi'));

    expect(handleClick).not.toHaveBeenCalled();
  });

  it("affiche l'état de chargement", () => {
    render(<Button isLoading>Envoyer</Button>);

    expect(screen.getByText('Chargement...')).toBeInTheDocument();
    expect(screen.queryByText('Envoyer')).not.toBeInTheDocument();
  });

  it('désactive le bouton quand isLoading', () => {
    render(<Button isLoading>Envoyer</Button>);

    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('applique les classes de variante correctement', () => {
    const { rerender } = render(<Button variant="primary">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-blue-500');

    rerender(<Button variant="danger">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-red-500');
  });
});

Tester des Hooks Personnalisés

// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue] as const;
}
// src/hooks/useLocalStorage.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
    vi.clearAllMocks();
  });

  it('retourne la valeur initiale quand localStorage est vide', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'default'));

    expect(result.current[0]).toBe('default');
  });

  it('retourne la valeur du localStorage quand elle existe', () => {
    localStorage.setItem('test-key', JSON.stringify('stored-value'));

    const { result } = renderHook(() => useLocalStorage('test-key', 'default'));

    expect(result.current[0]).toBe('stored-value');
  });

  it('met à jour la valeur dans le state et localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));

    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated');
  });

  it('supporte une fonction de mise à jour', () => {
    const { result } = renderHook(() => useLocalStorage('counter', 0));

    act(() => {
      result.current[1]((prev) => prev + 1);
    });

    expect(result.current[0]).toBe(1);
  });

  it('fonctionne avec des objets complexes', () => {
    const initialUser = { name: 'Jean', age: 25 };
    const { result } = renderHook(() => useLocalStorage('user', initialUser));

    act(() => {
      result.current[1]({ ...result.current[0], age: 26 });
    });

    expect(result.current[0]).toEqual({ name: 'Jean', age: 26 });
  });
});

Tests E2E avec Playwright

Playwright est l'outil le plus complet pour les tests end-to-end en 2025. Il supporte plusieurs navigateurs et est significativement plus rapide que Cypress.

Configuration

npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Tests E2E Pratiques

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentification', () => {
  test('un utilisateur peut se connecter avec des identifiants valides', async ({ page }) => {
    await page.goto('/login');

    // Remplit le formulaire
    await page.getByLabel('Email').fill('utilisateur@exemple.com');
    await page.getByLabel('Mot de passe').fill('motdepasse123');

    // Soumet
    await page.getByRole('button', { name: 'Se connecter' }).click();

    // Vérifie la redirection
    await expect(page).toHaveURL('/dashboard');

    // Vérifie l'élément d'utilisateur connecté
    await expect(page.getByText('Bienvenue, Utilisateur')).toBeVisible();
  });

  test('affiche une erreur avec des identifiants invalides', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('utilisateur@exemple.com');
    await page.getByLabel('Mot de passe').fill('mauvaismdp');
    await page.getByRole('button', { name: 'Se connecter' }).click();

    await expect(page.getByText('Email ou mot de passe invalide')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('redirige un utilisateur non authentifié vers login', async ({ page }) => {
    await page.goto('/dashboard');

    await expect(page).toHaveURL('/login?redirect=/dashboard');
  });
});
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Panier', () => {
  test.beforeEach(async ({ page }) => {
    // Login avant chaque test
    await page.goto('/login');
    await page.getByLabel('Email').fill('utilisateur@exemple.com');
    await page.getByLabel('Mot de passe').fill('motdepasse123');
    await page.getByRole('button', { name: 'Se connecter' }).click();
    await expect(page).toHaveURL('/dashboard');
  });

  test('ajoute un produit au panier', async ({ page }) => {
    await page.goto('/produits');

    // Clique sur le premier produit
    await page.getByTestId('product-card').first().click();

    // Ajoute au panier
    await page.getByRole('button', { name: 'Ajouter au Panier' }).click();

    // Vérifie le compteur du panier
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });

  test('met à jour la quantité dans le panier', async ({ page }) => {
    // Ajoute un produit d'abord
    await page.goto('/produits/1');
    await page.getByRole('button', { name: 'Ajouter au Panier' }).click();

    // Va au panier
    await page.goto('/panier');

    // Augmente la quantité
    await page.getByRole('button', { name: 'Augmenter quantité' }).click();

    // Vérifie la quantité mise à jour
    await expect(page.getByTestId('item-quantity')).toHaveValue('2');
  });

  test('complète le checkout avec succès', async ({ page }) => {
    // Setup: ajoute un produit
    await page.goto('/produits/1');
    await page.getByRole('button', { name: 'Ajouter au Panier' }).click();
    await page.goto('/panier');

    // Démarre le checkout
    await page.getByRole('button', { name: 'Finaliser la Commande' }).click();

    // Remplit les données de livraison
    await page.getByLabel('Code Postal').fill('75001');
    await page.getByRole('button', { name: 'Rechercher' }).click();
    await expect(page.getByLabel('Rue')).toHaveValue(/Rivoli/);

    await page.getByLabel('Numéro').fill('100');

    // Sélectionne le mode de paiement
    await page.getByLabel('Carte de Crédit').check();
    await page.getByLabel('Numéro de Carte').fill('4111111111111111');
    await page.getByLabel('Validité').fill('12/28');
    await page.getByLabel('CVV').fill('123');

    // Confirme
    await page.getByRole('button', { name: 'Confirmer la Commande' }).click();

    // Vérifie le succès
    await expect(page).toHaveURL(/\/commande\/\d+/);
    await expect(page.getByText('Commande passée avec succès')).toBeVisible();
  });
});

Patterns et Bonnes Pratiques

Page Object Model

// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Mot de passe');
    this.submitButton = page.getByRole('button', { name: 'Se connecter' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// Utilisation dans le test
test('login avec page object', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('utilisateur@exemple.com', 'motdepasse123');

  await expect(page).toHaveURL('/dashboard');
});

Fixtures Personnalisées

// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type Fixtures = {
  loginPage: LoginPage;
  authenticatedPage: void;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },

  authenticatedPage: async ({ page }, use) => {
    // Setup: fait le login
    await page.goto('/login');
    await page.getByLabel('Email').fill('utilisateur@exemple.com');
    await page.getByLabel('Mot de passe').fill('motdepasse123');
    await page.getByRole('button', { name: 'Se connecter' }).click();
    await page.waitForURL('/dashboard');

    // Exécute le test
    await use();

    // Cleanup: peut faire logout ou nettoyer des données
  },
});

export { expect } from '@playwright/test';

Conclusion

Les tests automatisés dans le front-end ont cessé d'être un luxe pour devenir une nécessité. Avec Vitest et Playwright, vous disposez d'outils modernes, rapides et agréables à utiliser.

Commencez par :

  1. Des tests unitaires pour les fonctions utilitaires
  2. Des tests de composants pour l'UI critique
  3. Quelques tests E2E pour les flux principaux

N'essayez pas d'avoir 100% de couverture immédiatement. Concentrez-vous sur tester ce qui compte le plus et élargissez progressivement.

Si vous voulez approfondir vos connaissances en JavaScript et ses meilleures pratiques, je vous recommande de jeter un œil à un autre article : ECMAScript 2025 : Les Nouvelles Fonctionnalités de JavaScript où vous découvrirez les nouveautés du langage.

C'est parti ! 🦅

📚 Vous Voulez Approfondir Vos Connaissances en JavaScript ?

Cet article a couvert les tests, mais il y a beaucoup plus à explorer dans le monde du développement moderne.

Les développeurs qui investissent dans une connaissance solide et structurée tendent à avoir plus d'opportunités sur le marché.

Matériel d'Étude Complet

Si vous voulez maîtriser JavaScript du basique à l'avancé, j'ai préparé un guide complet :

Options d'investissement :

  • €9,90 (paiement unique)

👉 Découvrir le Guide JavaScript

💡 Matériel mis à jour avec les meilleures pratiques du marché

Commentaires (0)

Cet article n'a pas encore de commentaires. Soyez le premier!

Ajouter des commentaires