Тестирование компонентов
Введение
Playwright Test позволяет тестировать компоненты веб-приложения изолированно. Тест компонента — это обычный сквозной тест Playwright, который запускается на небольшой странице галереи историй, обслуживаемой вашим сервером разработки. Для этого не нужна отдельная среда выполнения для тестирования компонентов, интеграция со сборщиком или дополнительные npm-пакеты — всё делает встроенная фикстура fixtures.mount() из @playwright/test.
import { test, expect } from '@playwright/test';
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
}); Тесты запускаются в Node.js, а компоненты работают в настоящем браузере: выполняются реальные клики, работает настоящая вёрстка, возможна визуальная регрессия. При этом тестам доступны все возможности Playwright Test: параллельное выполнение, параметризация, повторные попытки и трассировка после сбоя.
примечаниеЭкспериментальные пакеты
@playwright/experimental-ct-react,-ct-react17и-ct-vueудалены и больше не публикуются. Если вы всё ещё используете их, оставайтесь на Playwright 1.62, пока не выполните описанную ниже инструкцию по миграции.
Почему подход не зависит от фреймворка
Пакеты @playwright/experimental-ct-* позволяют писать JSX прямо в тестах — mount(<Button onClick={spy} />). Чтобы это стало возможным, Playwright должен был управлять всем конвейером: находить компоненты в тестах, собирать пакет со своей копией Vite и собственной конфигурацией, обслуживать его на собственном сервере и передавать props и обратные вызовы через границу между Node.js и браузером.
Из-за такой архитектуры пакеты так и оставались экспериментальными:
-
Решение работало только при совпадении вашей конфигурации с нашей. Псевдонимы путей, плагины и обработку CSS приходилось вручную дублировать в
ctViteConfig. Проекты на webpack, Next.js или с пользовательскими конвейерами не могли использовать собственную сборку. Для каждого фреймворка требовался отдельный пакет со своей обвязкой для среды выполнения, а каждый новый фреймворк означал ещё один пакет. - Граница между Node.js и браузером давала сбои. JSX, написанный в тесте, компилировался в Node.js и собирался заново в браузере. Передавать живые объекты было нельзя, обратные вызовы работали лишь частично из-за механизма передачи, а подмены модулей незаметно не применялись.
Новый подход меняет направление управления:
- Вы управляете конвейером. Компоненты собираются и обслуживаются вашим сервером разработки, с вашими плагинами, псевдонимами и CSS. Playwright ничего не компилирует и не обслуживает — он переходит на страницу, как и в любом другом тесте.
- Подход не зависит от фреймворка. Единственная часть, специфичная для фреймворка, — страница галереи, небольшой модуль, которым вы управляете. React, Vue, Svelte, Solid или что-то ещё: если ваш сервер разработки может это отобразить, Playwright может это протестировать.
-
Решение стабильно. Тесты импортируют
testиexpectиз обычного@playwright/test, а fixtures.mount() — это встроенная документированная фикстура. Не нужно зависеть от экспериментального пакета или использовать отдельный формат конфигурации.
Как это работает
Вся модель состоит из трёх понятий:
- История — небольшой компонент-обёртка, который помещает тестируемый компонент в определённый сценарий: фиксированные props, тестовые данные, провайдеры, записанные обратные вызовы. Истории находятся рядом с компонентом в файлах
*.story.tsx(или.ts/.jsx/.js/.vue); каждый именованный экспорт — отдельная история. - Галерея — одна страница, обслуживаемая вашим сервером разработки. Она предоставляет функции
window.mount(params)иwindow.unmount(), которые отображают историю — найденную в файлах историй — в элемент#root. Реализация зависит от фреймворка, и вы сами за неё отвечаете. - Фикстура fixtures.mount() переходит в галерею (testOptions.baseURL), вызывает
window.mount()с идентификатором истории и props и возвращает Locator корневого элемента галереи. Ограничивайте поиск этим элементом:component.getByRole('button').click().
Всё необходимое компоненту настраивается внутри истории, которая выполняется в браузере. Всё, что проверяет тест, можно наблюдать на странице: DOM, URL, сеть.
Начало работы
Шаг 1. Укажите навык своему агенту для написания кода
Галерея — это код приложения, и она принадлежит вам, а не Playwright. Быстрее всего получить её, не написав самостоятельно: Playwright предоставляет всю эту методологию в виде навыка для агента. Установите навыки и попросите своего агента для написания кода (Claude Code, GitHub Copilot или другого) выполнить настройку:
npx playwright init-skills
Set up component testing using the playwright-component-testing skill.
Агент определит ваш фреймворк и сборщик, реализует галерею для вашего стека, добавит проект Playwright в конфигурацию и создаст первую историю и спецификацию.
Контракт, которому должна соответствовать галерея, невелик и полезен для понимания, даже если вы никогда не откроете этот файл:
- Это одна страница в
playwright/gallery/, которую обслуживает ваш собственный сервер разработки. Приложения на Vite обслуживают её уже запущенным сервером разработки; в других конфигурациях рядом с приложением запускается небольшой отдельный сервер Vite. - Галерея находит ваши файлы
*.story.*и предоставляет две функции:window.mount({ story, props })отображает историю с заданным идентификатором в элемент#root, аwindow.unmount()удаляет её. Если история не найдена или при отображении возникла ошибка, возвращается отклонённый промис; в результате вызовmount()в тесте выбрасывает исключение. - Корень отображения повторно используется при следующих вызовах, поэтому
component.update(props)обновляет его вместо повторного монтирования, а состояние компонента сохраняется. - Глобальный CSS импортируется так же, как и в точке входа приложения, а содержимое
window.mount— естественное место для общей настройки приложения, аналог старых хуковbeforeMount/afterMount.
Если вы предпочитаете написать галерею вручную, установленный навык содержит полную спецификацию и подробные примеры для React и Vue в references/gallery-spec.md — вся страница занимает всего несколько десятков строк.
Шаг 2. Настройте Playwright
Добавьте проект в playwright.config.ts и укажите галерею в baseURL:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'components',
testDir: './tests/components',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:5173/playwright/gallery/index.html',
serviceWorkers: 'block',
reuseContext: true,
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
}); mount переходит на baseURL, поэтому здесь нужно указать галерею. serviceWorkers: 'block' не позволяет собственному service worker приложения отдавать кэшированные ответы, которые могут подменить ответы ваших имитаций page.route(). reuseContext: true повторно использует контекст браузера между тестами в одном рабочем процессе — это значительно ускоряет наборы тестов компонентов и та же оптимизация, которую экспериментальные пакеты применяли неявно.
Шаг 3. Создайте историю
Истории располагаются рядом с тестируемым компонентом. Каждый именованный экспорт задаёт отдельный сценарий:
import { Button } from './Button';
export const Primary = () => <Button title='Submit' />;
export const Disabled = () => <Button title='Submit' disabled />; Шаг 4. Напишите тест
import { test, expect } from '@playwright/test';
test('renders primary button', async ({ mount }) => {
const component = await mount('components/Button/Primary');
await expect(component.getByRole('button')).toHaveText('Submit');
});
test('disabled button is disabled', async ({ mount }) => {
const component = await mount('components/Button/Disabled');
await expect(component.getByRole('button')).toBeDisabled();
}); Шаг 5. Запустите тесты
npx playwright test --project=components
Истории как методология
Истории — это не просто обходной путь для тестирования, а доступная для поиска и просмотра документация о состояниях компонентов. Соглашения помогают поддерживать её в актуальном состоянии:
-
Один экспорт на сценарий. Предпочитайте новый экспорт истории параметризации существующей.
Button.story.tsxэкспортPrimary,Disabled,WithLongTitleчитается как спецификация компонента. -
Истории находятся рядом с компонентом.
src/components/Button.story.tsxописываетsrc/components/Button.tsx. Переименования и рефакторинг затрагивают оба файла одновременно. -
Идентификаторы историй формируются на основе пути к файлу: путь внутри
src/без расширения.story.*, а затем имя экспорта —components/Button/Primary. Подойдёт и любой уникальный суффикс:mount('Button/Primary'). - История отвечает за всё необходимое компоненту: провайдеры, тестовые данные, состояние, обратные вызовы. Тест отвечает только за взаимодействия и проверки.
Поскольку каждая история — это именованное состояние страницы, к которому можно обратиться, галерея одновременно служит живым каталогом: откройте URL галереи в браузере и отобразите любую историю, чтобы визуально её проверить.
Шаблоны тестирования
Запись состояния для проверок
Компоненты принимают обратные вызовы, а тестам нужно проверить, что они были вызваны. Вместо передачи обратных вызовов между Node.js и браузером история хранит состояние и предоставляет обратные вызовы, а также записывает наблюдаемый результат в скрытую форму рядом с компонентом:
- React
- Vue
import { useState } from 'react';
import { Expandable } from './Expandable';
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title='Title'>Details</Expandable>
<form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
</>;
};import { defineComponent, h, ref } from 'vue';
import Expandable from './Expandable.vue';
export const Stateful = defineComponent(() => {
const expanded = ref(false);
return () => h('div', [
h(Expandable, {
'expanded': expanded.value,
'onUpdate:expanded': (value: boolean) => expanded.value = value,
'title': 'Title',
}),
h('form', { hidden: true }, [
h('input', { 'data-testid': 'expanded', 'readonly': true, 'value': String(expanded.value) }),
]),
]);
});test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
}); Этот шаблон лежит в основе методологии:
- Весь сценарий выполняется в браузере — обратные вызовы не передаются, граница между Node.js и браузером не создаёт проблем.
-
toHaveValue()— это проверка с ожиданием, которая повторяется, пока не появится нужное состояние. Не нужно ничего ожидать или опрашивать вручную. - Записывайте каждое наблюдаемое значение в отдельное поле ввода
data-testid:String(...)— для скалярных значений,JSON.stringify(...)— для полезных данных. Проверка отсутствия изменений выполняется так же: выполните операцию, а затем убедитесь, что значение не изменилось. - Записанное состояние видно, когда вы открываете историю в галерее. Вручную нажмите на компонент и следите за изменениями значений — история одновременно служит страницей для ручной проверки именно того сценария, который охватывает автоматизированный тест. Оставьте форму
hidden, чтобы получить чистый исходный вид для снимка экрана, или уберите атрибутhiddenво время разработки, чтобы видеть состояние рядом с компонентом.
Props для отдельных тестов
Если сценарий удобно параметризовать, передайте обычные сериализуемые props вторым аргументом в mount. Галерея передаст их истории в качестве props:
import { Button } from './Button';
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />; import type { WithTitle } from '../../src/components/Button.story';
const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' }); mount обобщён по типу истории: передайте тип истории в качестве аргумента шаблона, чтобы проверить типы props (и update()) по сигнатуре истории. Используйте только обычные сериализуемые данные; обратные вызовы должны находиться внутри истории.
Изменение props с помощью update()
Чтобы проверить реакцию компонента на изменение props без повторного монтирования и с сохранением состояния, вызовите component.update(newProps). Функция повторно отобразит ту же историю с новыми props в существующем корне:
const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2'); Несколько состояний и визуальное сравнение
Каждый вызов mount() выполняет новый переход, поэтому тесты полностью изолированы, а монтирование нескольких историй в одном тесте обходится недорого:
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png'); Создавайте снимок экрана возвращённого локатора корневого элемента, а не всей страницы, чтобы не проверять лишнее содержимое, которое вы можете добавить в галерею.
Обработка сетевых запросов
Используйте page.route() как обычно — регистрируйте маршруты до вызова mount(), поскольку монтирование приводит к переходу:
test('renders the error state', async ({ page, mount }) => {
await page.route('**/api/items', route => route.fulfill({ status: 500 }));
const component = await mount('components/ItemList/Default');
await expect(component.getByRole('alert')).toContainText('Something went wrong');
}); Параметр serviceWorkers: 'block' в конфигурации не позволяет собственному service worker приложения отдавать кэшированные ответы, которые могут подменить маршруты. Команды, использующие библиотеки обработчиков MSW, могут запускать worker внутри истории или декоратора.
Отладка историй
Откройте URL галереи в браузере и вызовите await window.mount({ story: 'components/Button/Primary' }) в консоли инструментов разработчика — именно это делает фикстура mount. Если история не найдена или при отображении возникла ошибка, вызов window.mount завершается с ошибкой; в результате вызов mount() в тесте выбрасывает исключение с настоящей трассировкой стека. Чтобы просматривать истории без консоли, добавьте в галерею необязательную индексную страницу со списком всех найденных историй.
Миграция с экспериментальных пакетов
Экспериментальные пакеты компилировали JSX в файле теста и передавали его в браузер. Подход с галереей переносит сценарий в экспорт истории, который выполняется непосредственно в браузере. Вот как соотносятся понятия:
@playwright/experimental-ct-* |
Галерея историй |
|---|---|
mount(<Button onClick={spy} />) |
История с состоянием: история предоставляет onClick и записывает результат в скрытое поле ввода; тест проверяет его с помощью toHaveValue()
|
| Простые props с данными из теста | Принцип не изменился: mount(id, props)
|
| Дочерние элементы JSX / слоты из теста | Отдельный экспорт истории для каждой композиции (Vue: файл .story.vue для сценариев с большим количеством слотов) |
component.update(<Button count={2} />) |
component.update({ count: 2 }) |
component.unmount() |
component.unmount() |
Хуки beforeMount / afterMount |
Содержимое window.mount галереи (глобальная настройка) или декораторы историй (для отдельных историй) |
Вариации hooksConfig для каждого теста |
Props: mount('App/Routing', { route: '/dashboard' }), интерпретируемые историей |
Фикстура router / обработчики MSW в Node.js |
page.route() в тесте или MSW setupWorker внутри истории |
playwright/index.html (стили, тема) |
index.html галереи и импорты модуля точки входа |
ctViteConfig, ctPort, ctTemplateDir
|
Больше не нужны: галерея работает через ваш сервер разработки; порт задаётся в webServer и baseURL
|
defineConfig из пакета ct |
Обычный defineConfig из @playwright/test
|
Типичная спецификация после миграции выглядит так:
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';
test('counts clicks', async ({ mount }) => {
let clicks = 0;
const component = await mount(<Button title='Submit' onClick={() => ++clicks} />);
await component.getByRole('button').click();
expect(clicks).toBe(1);
}); import { useState } from 'react';
import { Button } from './Button';
export const CountsClicks = () => {
const [clicks, setClicks] = useState(0);
return <>
<Button title='Submit' onClick={() => setClicks(count => count + 1)} />
<form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
</>;
}; import { test, expect } from '@playwright/test';
test('counts clicks', async ({ mount }) => {
const component = await mount('components/Button/CountsClicks');
await component.getByRole('button').click();
await expect(component.getByTestId('click-count')).toHaveValue('1');
}); Выполняйте миграцию постепенно: оставаясь на Playwright 1.62, настройте галерею и проект components наряду со старым проектом CT, переносите спецификации по одной, а затем удалите зависимость @playwright/experimental-ct-* вместе с playwright/index.html, playwright/index.ts и playwright/.cache и обновите Playwright.
На что обратить внимание:
-
Идентификаторы историй — это строки. Переименование или перемещение истории нарушит работу спецификаций во время выполнения, а не при компиляции. Использование
mount<typeof Story>как минимум связывает props с историей на этапе проверки типов. - JSX для отдельных тестов больше не используется. Вместо создания разных деревьев JSX для каждого теста создайте отдельный экспорт истории для каждой композиции. В этом и заключается смысл: каждую композицию, которую стоит тестировать, стоит назвать и вынести на проверку.
Часто задаваемые вопросы
Как получить доступ к методам компонента или его экземпляру?
Обращаться к внутренним методам компонента или его экземпляру из кода теста не рекомендуется и не поддерживается. Вместо этого наблюдайте за компонентом и взаимодействуйте с ним с точки зрения пользователя: нажимайте на него, проверяйте страницу и записывайте внутренние результаты в DOM через историю. Тесты становятся надёжнее и полезнее, если не зависят от деталей реализации. Если тест не проходит при проверке с точки зрения пользователя, вероятно, автоматизированный тест обнаружил настоящую ошибку.
Можно ли продолжать использовать плагины сборщика, псевдонимы и настройки CSS?
Да — это основа подхода. Галерею обслуживает ваш собственный сервер разработки, поэтому истории могут отображать всё, что может отобразить приложение. Не нужно поддерживать синхронность двух конфигураций сборщика.
А что насчёт фреймворков, отличных от React и Vue?
Попросите агента для написания кода реализовать контракт галереи для вашего фреймворка: находить компонент по идентификатору истории, отображать его в #root и повторно использовать корень между вызовами, чтобы update() сохранял состояние. Фикстура mount не знает и не интересуется, какой фреймворк используется.
© 2026 Microsoft
Licensed under the Apache License, Version 2.0.
https://playwright.dev/docs/test-components