Le package @playwright/experimental-ct-react de Playwright permet de monter des composants React (ou Vue, Svelte) individuels dans un vrai navigateur. Ils s'y testent avec les mêmes locators et assertions qu'en tests E2E, sans faire tourner l'ensemble de la stack applicative.

Quand les tests de composants ont du sens

Utilisez les tests de composants pour :
  • Les composants UI avec une logique d'interaction complexe (menus déroulants, modals, sélecteurs de dates)
  • Les composants difficiles à atteindre via les tests E2E (états limites, états d'erreur)
  • Les composants du système de design qui nécessitent une vérification de cohérence visuelle
  • Tester la gestion des événements et des props sans l'application complète
Ne remplacez pas les tests E2E par des tests de composants :

Les tests de composants ne couvrent pas le routage, l'authentification, l'intégration API, ni l'interaction entre composants. Les deux sont nécessaires.

Configuration

npm init playwright@latest -- --ct
# Ou pour ajouter à un projet existant :
npm install --save-dev @playwright/experimental-ct-react

Cela crée un playwright-ct.config.ts séparé de votre config Playwright habituelle :

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.{ts,tsx}',
  
  use: {
    ctPort: 3100,  // Port du serveur de tests de composants
    viewport: { width: 1280, height: 720 },
  },
  
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Votre premier test de composant

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

export function Button({ label, variant = 'primary', disabled, onClick }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      disabled={disabled}
      onClick={onClick}
      data-testid="button"
    >
      {label}
    </button>
  );
}

// components/Button.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('s\'affiche avec le bon label', async ({ mount }) => {
  const component = await mount(<Button label="Cliquez ici" />);
  
  await expect(component).toContainText('Cliquez ici');
});

test('applique la classe de variante', async ({ mount }) => {
  const component = await mount(<Button label="Danger" variant="danger" />);
  
  await expect(component).toHaveClass(/btn-danger/);
});

test('un bouton désactivé ne peut pas être cliqué', async ({ mount }) => {
  const component = await mount(<Button label="Désactivé" disabled />);
  
  await expect(component.getByTestId('button')).toBeDisabled();
});

test('appelle onClick lors d\'un clic', async ({ mount }) => {
  let clicked = false;
  
  const component = await mount(
    <Button 
      label="Cliquez ici" 
      onClick={() => { clicked = true; }} 
    />
  );
  
  await component.click();
  
  expect(clicked).toBe(true);
});

Exécuter les tests de composants :

npx playwright test --config playwright-ct.config.ts

Tester un composant de formulaire

// components/LoginForm.tsx
interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
  error?: string;
}

export function LoginForm({ onSubmit, error }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      onSubmit(email, password);
    }}>
      <input
        data-testid="email"
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        data-testid="password"
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        placeholder="Mot de passe"
      />
      {error && <p data-testid="error">{error}</p>}
      <button type="submit" data-testid="submit">Se connecter</button>
    </form>
  );
}

// components/LoginForm.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { LoginForm } from './LoginForm';

test('soumet avec les bons identifiants', async ({ mount }) => {
  let submittedEmail = '';
  let submittedPassword = '';
  
  const component = await mount(
    <LoginForm
      onSubmit={(email, password) => {
        submittedEmail = email;
        submittedPassword = password;
      }}
    />
  );
  
  await component.getByTestId('email').fill('user@test.com');
  await component.getByTestId('password').fill('ValidPass1');
  await component.getByTestId('submit').click();
  
  expect(submittedEmail).toBe('user@test.com');
  expect(submittedPassword).toBe('ValidPass1');
});

test('affiche le message d\'erreur quand fourni', async ({ mount }) => {
  const component = await mount(
    <LoginForm
      onSubmit={() => {}}
      error="Identifiants invalides"
    />
  );
  
  await expect(component.getByTestId('error')).toHaveText('Identifiants invalides');
});

test('ne montre pas d\'erreur quand error est undefined', async ({ mount }) => {
  const component = await mount(
    <LoginForm onSubmit={() => {}} />
  );
  
  await expect(component.getByTestId('error')).not.toBeVisible();
});

Tester un composant dropdown

Les composants complexes avec état sont là où les tests de composants brillent :

// components/Dropdown.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Dropdown } from './Dropdown';

const options = [
  { value: 'chrome', label: 'Chrome' },
  { value: 'firefox', label: 'Firefox' },
  { value: 'safari', label: 'Safari' },
];

test('le dropdown montre les options quand cliqué', async ({ mount }) => {
  const component = await mount(
    <Dropdown options={options} placeholder="Sélectionner un navigateur" />
  );
  
  // Initialement fermé
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
  
  // Clic pour ouvrir
  await component.getByTestId('dropdown-trigger').click();
  
  // Maintenant visible avec toutes les options
  await expect(component.getByTestId('dropdown-list')).toBeVisible();
  
  const items = component.getByTestId('dropdown-option');
  await expect(items).toHaveCount(3);
  await expect(items.nth(0)).toHaveText('Chrome');
});

test('se ferme après la sélection d\'une option', async ({ mount }) => {
  let selected = '';
  
  const component = await mount(
    <Dropdown
      options={options}
      placeholder="Sélectionner un navigateur"
      onSelect={(value) => { selected = value; }}
    />
  );
  
  await component.getByTestId('dropdown-trigger').click();
  await component.getByText('Firefox').click();
  
  expect(selected).toBe('firefox');
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
  await expect(component.getByTestId('dropdown-trigger')).toHaveText('Firefox');
});

test('se ferme avec la touche Escape', async ({ mount }) => {
  const component = await mount(
    <Dropdown options={options} placeholder="Sélectionner" />
  );
  
  await component.getByTestId('dropdown-trigger').click();
  await expect(component.getByTestId('dropdown-list')).toBeVisible();
  
  await component.press('Escape');
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
});

Mettre à jour les composants en cours de test

Vous pouvez re-rendre un composant avec des props différentes :

test('le message d\'erreur se met à jour quand les props changent', async ({ mount }) => {
  const component = await mount(
    <LoginForm onSubmit={() => {}} />
  );
  
  // Initialement pas d'erreur
  await expect(component.getByTestId('error')).not.toBeVisible();
  
  // Mettre à jour les props pour ajouter une erreur
  await component.update(
    <LoginForm onSubmit={() => {}} error="Session expirée" />
  );
  
  await expect(component.getByTestId('error')).toHaveText('Session expirée');
  
  // Supprimer l'erreur
  await component.update(
    <LoginForm onSubmit={() => {}} />
  );
  
  await expect(component.getByTestId('error')).not.toBeVisible();
});

Hooks dans les tests de composants

Les tests de composants Playwright prennent en charge les mêmes hooks que les tests Playwright habituels :

import { test, expect, beforeEach } from '@playwright/experimental-ct-react';

let clickCount = 0;

test.beforeEach(() => {
  clickCount = 0;
});

test('compte les clics correctement', async ({ mount }) => {
  const component = await mount(
    <Button label="Cliquer" onClick={() => { clickCount++; }} />
  );
  
  await component.click();
  await component.click();
  await component.click();
  
  expect(clickCount).toBe(3);
});

Tests visuels avec les composants

Les tests de composants fonctionnent bien avec la régression visuelle :

test('les variantes de bouton ont le bon aspect', async ({ mount }) => {
  for (const variant of ['primary', 'secondary', 'danger'] as const) {
    const component = await mount(<Button label={variant} variant={variant} />);
    await expect(component).toHaveScreenshot(`button-${variant}.png`);
  }
});

C'est plus fiable que les captures pleine page : moins d'éléments variables, plus facile d'isoler ce qui a changé.

Vue et Svelte

Les mêmes concepts s'appliquent à Vue et Svelte :

# Vue
npm install --save-dev @playwright/experimental-ct-vue

# Svelte
npm install --save-dev @playwright/experimental-ct-svelte

<!-- Test Vue -->
<script setup>
import { test, expect } from '@playwright/experimental-ct-vue'
import MyComponent from './MyComponent.vue'
</script>

Composant vs E2E : lequel utiliser

| Scénario | Test de composant | Test E2E |

|---|---|---|

| Gestionnaire de clic sur un bouton | Oui | Non |

| Dropdown qui s'ouvre/se ferme | Oui | Non |

| Messages de validation de formulaire | Oui | Non |

| Flux de connexion complet | Non | Oui |

| Navigation entre pages | Non | Oui |

| Intégration API | Non | Oui |

| Régression visuelle de composant | Oui | Parfois |

Récapitulatif

# Configuration
npm install --save-dev @playwright/experimental-ct-react

# Config : playwright-ct.config.ts
# Tests : ComponentName.ct.spec.tsx

# Lancer
npx playwright test --config playwright-ct.config.ts

Opérations clés :

  • mount() : rend un composant dans un vrai navigateur
  • component.getByTestId(...) : locators Playwright standard
  • component.click() : actions Playwright standard
  • component.update() : re-rend avec de nouvelles props
  • expect(component).toHaveScreenshot() : régression visuelle

Les tests de composants comblent l'écart entre les tests unitaires (trop isolés) et les tests E2E (trop lents). Notez que le package reste marqué experimental : l'API peut changer entre les versions majeures de Playwright.

→ See also: La Pyramide des Tests Expliquée pour les Ingénieurs QA | Assertions dans Playwright: Le Guide Complet | Tests de Régression Visuelle avec Playwright: toHaveScreenshot sans Applitools