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
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-reactCela 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.tsTester 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.tsOpérations clés :
mount(: rend un composant dans un vrai navigateur) component.getByTestId(...): locators Playwright standardcomponent.click(): actions Playwright standardcomponent.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.