Пакет @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(...): стандартные локаторы Playwright
  • component.click(): стандартные действия Playwright
  • component.update(): перерендер с новыми пропсами
  • expect(component).toHaveScreenshot(): визуальная регрессия
→ See also: Пирамида тестирования: объяснение для QA-инженеров | Assertions в Playwright: полное руководство | Визуальное регрессионное тестирование с Playwright: toHaveScreenshot без Applitools