Пакет @playwright/experimental-ct-react монтирует отдельные React-компоненты в реальном браузере через фикстуру mount(). Так можно протестировать поведение дропдауна при нажатии Escape или состояние ошибки формы, не поднимая весь стек приложения. Те же локаторы и ассёрты что в обычных Playwright-тестах, отдельный playwright-ct.config.ts. Эта статья разбирает настройку, тестирование пропсов и обработчиков событий, перерендеринг с новыми пропсами прямо в тесте, визуальную регрессию на уровне компонента и когда компонентные тесты заменяют E2E, а когда нет.
Когда компонентное тестирование оправдано
Компонентные тесты полезны для:
- UI-компонентов со сложной логикой взаимодействия (дропдауны, модалки, date picker'ы)
- Компонентов которые сложно проверить через E2E (граничные состояния, состояния ошибок)
- Компонентов дизайн-системы где нужна проверка визуального соответствия
- Тестирования обработчиков событий и пропсов без всего приложения
E2E этим не заменить. Компонентные тесты не покрывают роутинг, аутентификацию, API-интеграцию и взаимодействие между компонентами. Нужны оба вида.
Установка
npm init playwright@latest -- --ct
# или добавить в существующий проект:
npm install --save-dev @playwright/experimental-ct-reactСоздаётся отдельный playwright-ct.config.ts, независимый от обычного конфига Playwright:
// 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, // порт для сервера компонентных тестов
viewport: { width: 1280, height: 720 },
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Первый компонентный тест
// 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('renders with correct label', async ({ mount }) => {
const component = await mount(<Button label="Click me" />);
await expect(component).toContainText('Click me');
});
test('applies variant class', async ({ mount }) => {
const component = await mount(<Button label="Danger" variant="danger" />);
await expect(component).toHaveClass(/btn-danger/);
});
test('disabled button cannot be clicked', async ({ mount }) => {
const component = await mount(<Button label="Disabled" disabled />);
await expect(component.getByTestId('button')).toBeDisabled();
});
test('calls onClick when clicked', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button
label="Click me"
onClick={() => { clicked = true; }}
/>
);
await component.click();
expect(clicked).toBe(true);
});Запуск:
npx playwright test --config playwright-ct.config.tsТестирование формы
// 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="Password"
/>
{error && <p data-testid="error">{error}</p>}
<button type="submit" data-testid="submit">Sign In</button>
</form>
);
}// components/LoginForm.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { LoginForm } from './LoginForm';
test('submits with correct credentials', 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('shows error message when provided', async ({ mount }) => {
const component = await mount(
<LoginForm
onSubmit={() => {}}
error="Invalid credentials"
/>
);
await expect(component.getByTestId('error')).toHaveText('Invalid credentials');
});
test('does not show error when error is undefined', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Тестирование дропдауна
Компоненты с внутренним состоянием, где компонентные тесты особенно хорошо себя показывают:
// 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('dropdown shows options when clicked', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="Select browser" />
);
// изначально закрыт
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
// открываем кликом
await component.getByTestId('dropdown-trigger').click();
// список виден, три опции
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('closes after selecting an option', async ({ mount }) => {
let selected = '';
const component = await mount(
<Dropdown
options={options}
placeholder="Select browser"
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('closes on Escape key', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="Select" />
);
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();
});Обновление пропсов прямо в тесте
Компонент можно перерендерить с новыми пропсами не создавая новый:
test('error message updates when props change', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
// изначально ошибки нет
await expect(component.getByTestId('error')).not.toBeVisible();
// обновляем пропсы, добавляем ошибку
await component.update(
<LoginForm onSubmit={() => {}} error="Session expired" />
);
await expect(component.getByTestId('error')).toHaveText('Session expired');
// убираем ошибку
await component.update(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Хуки в компонентных тестах
Работают так же как в обычных Playwright-тестах:
import { test, expect, beforeEach } from '@playwright/experimental-ct-react';
let clickCount = 0;
test.beforeEach(() => {
clickCount = 0;
});
test('counts clicks correctly', async ({ mount }) => {
const component = await mount(
<Button label="Click" onClick={() => { clickCount++; }} />
);
await component.click();
await component.click();
await component.click();
expect(clickCount).toBe(3);
});Визуальная регрессия на уровне компонента
test('button variants look correct', 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`);
}
});Надёжнее полностраничных скриншотов: меньше движущихся частей, проще изолировать что изменилось.
Vue и Svelte
Те же концепции работают для Vue и Svelte:
# Vue
npm install --save-dev @playwright/experimental-ct-vue
# Svelte
npm install --save-dev @playwright/experimental-ct-svelte<!-- Vue тест -->
<script setup>
import { test, expect } from '@playwright/experimental-ct-vue'
import MyComponent from './MyComponent.vue'
</script>Компонентные тесты vs E2E: что где
| Сценарий | Компонентный тест | E2E тест |
|----------|-------------------|----------|
| Обработчик клика кнопки | ✅ | ❌ |
| Дропдаун открывается и закрывается | ✅ | ❌ |
| Сообщения валидации формы | ✅ | ❌ |
| Флоу входа от начала до конца | ❌ | ✅ |
| Навигация между страницами | ❌ | ✅ |
| API-интеграция | ❌ | ✅ |
| Визуальная регрессия компонента | ✅ | Иногда |
Итог
# Установка
npm install --save-dev @playwright/experimental-ct-react
# Конфиг: playwright-ct.config.ts
# Тесты: ComponentName.ct.spec.tsx
# Запуск
npx playwright test --config playwright-ct.config.tsОсновные операции:
mount(: рендерит компонент в реальном браузере) component.getByTestId(...): стандартные локаторы Playwrightcomponent.click(): стандартные действия Playwrightcomponent.update(: перерендер с новыми пропсами) expect(component).toHaveScreenshot(): визуальная регрессия