Outils & Frameworks

Storybook et Tests Visuels : Valider vos Composants UI

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Storybook et Tests Visuels : Valider vos Composants UI

Storybook comme plateforme de tests

La plupart des équipes utilisent Storybook pour documenter leurs composants. C'est déjà utile. Mais peu exploitent son potentiel en tant que plateforme de tests.

La philosophie de Storybook est simple : chaque "story" est un état de composant rendu et vérifiable. En ajoutant les bons addons, ces stories deviennent des tests : tests d'accessibilité, tests d'interaction, tests visuels automatisés. Tout ça sans dupliquer le code de setup.

Installation et configuration

# Installer Storybook dans un projet React existant
npx storybook@latest init

# Addons essentiels pour les tests
npm install --save-dev \
  @storybook/addon-interactions \
  @storybook/testing-library \
  @storybook/jest \
  @storybook/addon-a11y \
  @chromatic-com/storybook

Configuration dans .storybook/main.ts :

import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
    '@chromatic-com/storybook',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
  features: {
    interactionsDebugger: true,
  },
};

export default config;

Écrire des stories testables

Une story bien écrite est à la fois de la documentation et un cas de test. Voici un composant Button avec des stories complètes :

// src/components/Button/Button.tsx
export interface ButtonProps {
  label: string;
  variant: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  loading?: boolean;
  onClick?: () => void;
}

export function Button({
  label,
  variant,
  size = 'md',
  disabled = false,
  loading = false,
  onClick,
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled || loading}
      onClick={onClick}
      aria-busy={loading}
    >
      {loading ? <span className="spinner" aria-label="Chargement..." /> : null}
      {label}
    </button>
  );
}
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: { type: 'select' },
      options: ['sm', 'md', 'lg'],
    },
  },
  args: {
    onClick: fn(), // Spy automatique sur onClick
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// Story de base
export const Primary: Story = {
  args: {
    label: 'Enregistrer',
    variant: 'primary',
  },
};

export const Secondary: Story = {
  args: {
    label: 'Annuler',
    variant: 'secondary',
  },
};

export const Danger: Story = {
  args: {
    label: 'Supprimer',
    variant: 'danger',
  },
};

export const Disabled: Story = {
  args: {
    label: 'Non disponible',
    variant: 'primary',
    disabled: true,
  },
};

export const Loading: Story = {
  args: {
    label: 'Enregistrement...',
    variant: 'primary',
    loading: true,
  },
};

// Stories pour toutes les tailles
export const AllSizes: Story = {
  render: () => (
    <div className="flex gap-4 items-center">
      <Button label="Petit" variant="primary" size="sm" />
      <Button label="Moyen" variant="primary" size="md" />
      <Button label="Grand" variant="primary" size="lg" />
    </div>
  ),
};

Tests d'interaction avec play()

La fonction play() permet de simuler des interactions utilisateur directement dans Storybook :

// src/components/LoginForm/LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { LoginForm } from './LoginForm';

const meta: Meta<typeof LoginForm> = {
  title: 'Forms/LoginForm',
  component: LoginForm,
};

export default meta;
type Story = StoryObj<typeof LoginForm>;

export const Default: Story = {};

// Ce test se joue dans le browser via Storybook
export const LoginSuccess: Story = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    // Taper l'email
    await userEvent.type(
      canvas.getByLabelText('Adresse email'),
      '[email protected]',
    );

    // Taper le mot de passe
    await userEvent.type(
      canvas.getByLabelText('Mot de passe'),
      'motdepasse123',
    );

    // Cliquer sur le bouton de connexion
    await userEvent.click(canvas.getByRole('button', { name: /se connecter/i }));

    // Vérifier que le handler a été appelé
    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'motdepasse123',
    });
  },
};

export const ValidationErrors: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Soumettre sans remplir les champs
    await userEvent.click(canvas.getByRole('button', { name: /se connecter/i }));

    // Vérifier les messages d'erreur
    await expect(canvas.getByText('Email requis')).toBeVisible();
    await expect(canvas.getByText('Mot de passe requis')).toBeVisible();
  },
};

export const InvalidEmail: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.type(
      canvas.getByLabelText('Adresse email'),
      'email-invalide',
    );

    await userEvent.click(canvas.getByRole('button', { name: /se connecter/i }));

    await expect(
      canvas.getByText('Format d\'email invalide')
    ).toBeVisible();
  },
};

Exécuter les tests en CI avec Storybook Test Runner

Le test runner permet d'exécuter toutes vos stories et leurs fonctions play() dans une vraie instance de navigateur :

npm install --save-dev @storybook/test-runner

Ajoutez le script dans package.json :

{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test-storybook": "test-storybook",
    "test-storybook:ci": "test-storybook --url http://localhost:6006"
  }
}

Configuration optionnelle dans .storybook/test-runner.ts :

import type { TestRunnerConfig } from '@storybook/test-runner';
import { checkA11y, injectAxe } from 'axe-playwright';

const config: TestRunnerConfig = {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page) {
    // Vérification d'accessibilité sur chaque story
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
      detailedReportOptions: { html: true },
    });
  },
};

export default config;

Pipeline CI :

# .github/workflows/storybook-tests.yml
name: Tests Storybook

on: [push, pull_request]

jobs:
  storybook-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run build-storybook -- --quiet

      - name: Servir et tester Storybook
        run: |
          npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "npx http-server storybook-static --port 6006 --silent" \
            "npx wait-on tcp:6006 && npm run test-storybook:ci"

Tests de régression visuelle avec Chromatic

Chromatic (le service officiel de Storybook) capture des screenshots de toutes vos stories et détecte les changements visuels :

npm install --save-dev chromatic
# Dans votre workflow CI
- name: Chromatic — Tests visuels
  uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    exitZeroOnChanges: true # Ne pas faire échouer le build sur les changements visuels
    autoAcceptChanges: main  # Auto-accepter sur main

Chromatic compare chaque commit avec la baseline et affiche les diffs visuels dans une interface de review. Un développeur peut approuver les changements intentionnels et bloquer les régressions.

Tests d'accessibilité intégrés

L'addon @storybook/addon-a11y utilise axe-core pour analyser chaque story :

// Forcer certaines règles dans une story spécifique
export const WithAccessibilityTest: Story = {
  parameters: {
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: true,
          },
        ],
      },
    },
  },
};

// Désactiver a11y pour une story (cas particuliers)
export const DecorativeImage: Story = {
  parameters: {
    a11y: { disable: true },
  },
};

Intégration avec Jest/Vitest

Les stories peuvent être réutilisées comme données de test dans Jest/Vitest, évitant la duplication :

// src/components/Button/__tests__/Button.test.tsx
import { render, screen } from '@testing-library/react';
import { composeStories } from '@storybook/react';
import * as stories from '../Button.stories';

// Compose les stories avec leurs decorators et args
const { Primary, Disabled, Loading } = composeStories(stories);

describe('Button', () => {
  it('affiche le label de la story Primary', () => {
    render(<Primary />);
    expect(screen.getByText('Enregistrer')).toBeInTheDocument();
  });

  it('est désactivé dans la story Disabled', () => {
    render(<Disabled />);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('est en état aria-busy dans la story Loading', () => {
    render(<Loading />);
    expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
  });
});

Tester des composants avec des états complexes

Pour les composants qui dépendent de contextes, de stores ou de routeurs :

// .storybook/preview.tsx
import type { Preview } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../src/providers/ThemeProvider';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false, // Pas de retry dans les tests
      staleTime: Infinity,
    },
  },
});

const preview: Preview = {
  decorators: [
    (Story) => (
      <QueryClientProvider client={queryClient}>
        <ThemeProvider>
          <MemoryRouter>
            <Story />
          </MemoryRouter>
        </ThemeProvider>
      </QueryClientProvider>
    ),
  ],
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
  },
};

export default preview;

Pour les composants qui fetchent des données, utilisez MSW (Mock Service Worker) intégré à Storybook :

// src/components/UserProfile/UserProfile.stories.tsx
import { http, HttpResponse } from 'msw';

export const Loaded: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', ({ params }) => {
          return HttpResponse.json({
            id: params.id,
            name: 'Alice Dupont',
            email: '[email protected]',
            avatar: 'https://example.com/avatar.webp',
          });
        }),
      ],
    },
  },
};

export const Loading: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', async () => {
          await new Promise((resolve) => setTimeout(resolve, Infinity)); // Suspend indéfiniment
          return HttpResponse.json({});
        }),
      ],
    },
  },
};

export const Error: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return new HttpResponse(null, { status: 500 });
        }),
      ],
    },
  },
};

Conclusion

Storybook transforme votre librairie de composants en une plateforme de tests complète. Les stories ne sont plus de la documentation statique — elles deviennent des specs exécutables qui vérifient les comportements, l'accessibilité, et l'apparence visuelle de vos composants.

L'avantage clé est la mutualisation : une story bien écrite sert à la documentation, aux tests d'interaction, aux tests visuels automatisés, et peut même être réutilisée dans vos tests Jest. Un investissement, plusieurs bénéfices.