В классической разработке интерфейсов Storybook длительное время воспринимался исключительно как каталог компонентов (UI Living Styleguide) и визуальная песочница для верстки. Тестирование логики взаимодействий при этом дублировалось в отдельных юнит-файлах с использованием React Testing Library и Jest/Vitest, что приводило к синхронизационным издержкам: разработчикам приходилось повторно объявлять одинаковые мок-данные, конфигурации тем и тестовые обертки (Providers).
Начиная с версии 8+, Storybook трансформировался в полнофункциональную среду компонентного тестирования. Архитектура объединяет три ключевых слоя:
args) и сценарии взаимодействия в одном месте.@storybook/test: Интегрированный API, построенный на базе движка утверждений Vitest и симуляции действий @testing-library/user-event.@storybook/test-runner: Инструмент CLI, преобразующий все опубликованные истории в исполняемый набор тестов на базе реального браузерного движка Playwright.Это позволяет автоматизировать не только проверку рендеринга и пользовательских взаимодействий, но и проводить статический аудит доступности (Accessibility / a11y) по стандартам WCAG с помощью встроенного механизма axe-core.
Взаимодействие компонентов внутри Storybook 8+ строится вокруг изоляции корневого элемента истории (canvasElement) и выполнения цепочки микрособытий внутри встроенного браузерного фрейма.
+-------------------------------------------------------------------------+
| Storybook 8+ CSF3 Story Declaration |
| meta = { component: TransferModal, decorators: [WithThemeProvider] } |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Story: WithPlayFunction |
| |
| play: async ({ canvasElement, step }) => { |
| const canvas = within(canvasElement); |
| await step('Ввод реквизитов', async () => { ... }); |
| await step('Подтверждение перевода', async () => { ... }); |
| } |
+-------------------------------------------------------------------------+
|
+------------------+------------------+
| |
v (Интерактивный UI) v (Автоматизация CI)
+------------------------------------+ +----------------------------------+
| Storybook Browser UI | | @storybook/test-runner |
| - Вкладка Interactions | | - Запуск через Playwright |
| - Пошаговый Timeline отладчик | | - Headless Chromium / Firefox |
| - Визуальная подсветка ARIA-ролей | | - preVisit / postVisit хуки |
+------------------------------------+ +----------------------------------+
|
v
+----------------------------------+
| axe-core a11y Audit Engine |
| - WCAG 2.1 AA/AAA валидация |
| - Контрастность цветов, ARIA |
+----------------------------------+
|
v
+----------------------------------+
| Pass / Fail в консоли CI |
+----------------------------------+Создание Component Story Format 3 файла с объявлением базовых, граничных и ошибочных состояний компонента через пропсы args.
Реализация пользовательских взаимодействий через within(canvasElement), userEvent и пошаговую группировку ассертов в step().
Запуск Storybook UI, проверка временной шкалы (Interactions Timeline) и инспекция доступности во вкладке Accessibility.
Конфигурирование .storybook/test-runner.ts с вызовом checkA11y в хуке postVisit для автоматической проверки WCAG.
Сборка статического Storybook через storybook build и запуск test-runner параллельно с веб-сервером в GitHub Actions.
Почему внутри play-функции Storybook критически важно производить поиск элементов через within(canvasElement), а не через глобальный объект screen?
500 Internal Server Error и пустых выборок без запуска полноценного бэкенда приложения.aria-label или некорректными ролями role="button".TransferForm.tsx#Рассмотрим React-компонент формы денежного перевода, использующий состояние, валидацию и асинхронную отправку данных.
import React, { useState } from 'react';
interface TransferFormProps {
onSuccess: (amount: number, recipient: string) => void;
maxDailyLimit?: number;
}
export function TransferForm({ onSuccess, maxDailyLimit = 50_000 }: TransferFormProps) {
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
const parsedAmount = parseFloat(amount);
if (!recipient.trim()) {
setError('Укажите идентификатор получателя');
return;
}
if (isNaN(parsedAmount) || parsedAmount <= 0) {
setError('Сумма перевода должна быть больше нуля');
return;
}
if (parsedAmount > maxDailyLimit) {
setError(`Превышен суточный лимит: ${maxDailyLimit.toLocaleString()} ₽`);
return;
}
setIsSubmitting(true);
// Симуляция сетевой задержки обработки транзакции
await new Promise((resolve) => setTimeout(resolve, 300));
setIsSubmitting(false);
onSuccess(parsedAmount, recipient);
};
return (
<form
onSubmit={handleSubmit}
aria-label="Форма денежного перевода"
className="p-6 bg-slate-900 border border-slate-800 rounded-xl max-w-md w-full space-y-4"
>
<h2 className="text-lg font-bold text-white">Перевод средств</h2>
<div>
<label htmlFor="recipient-input" className="block text-sm font-medium text-slate-300 mb-1">
Номер счета получателя
</label>
<input
id="recipient-input"
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="ACC-489201"
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
<div>
<label htmlFor="amount-input" className="block text-sm font-medium text-slate-300 mb-1">
Сумма перевода (₽)
</label>
<input
id="amount-input"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="5000"
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
{error && (
<div role="alert" className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 px-4 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
>
{isSubmitting ? 'Отправка...' : 'Подтвердить перевод'}
</button>
</form>
);
}play() функцией и step()#Используем пакет @storybook/test для симуляции действий пользователя и проверки ответа формы. Метод step() логически разбивает сложный тест на читаемые фазы в панели взаимодействия Storybook UI.
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect, fn } from '@storybook/test';
import { TransferForm } from './TransferForm';
const meta: Meta<typeof TransferForm> = {
title: 'Features/Payments/TransferForm',
component: TransferForm,
parameters: {
layout: 'centered',
},
args: {
onSuccess: fn(),
maxDailyLimit: 20_000,
},
};
export default meta;
type Story = StoryObj<typeof TransferForm>;
// Базовое пустое состояние формы
export const DefaultState: Story = {};
// Интерактивный сценарий успешного перевода средств
export const SuccessfulTransfer: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const recipientInput = canvas.getByLabelText('Номер счета получателя');
const amountInput = canvas.getByLabelText('Сумма перевода (₽)');
const submitButton = canvas.getByRole('button', { name: 'Подтвердить перевод' });
await step('Шаг 1: Заполнение номера счета получателя', async () => {
await userEvent.type(recipientInput, 'ACC-998877', { delay: 40 });
await expect(recipientInput).toHaveValue('ACC-998877');
});
await step('Шаг 2: Ввод валидной суммы перевода', async () => {
await userEvent.type(amountInput, '12500', { delay: 40 });
await expect(amountInput).toHaveValue(12500);
});
await step('Шаг 3: Отправка формы и проверка коллбэка', async () => {
await userEvent.click(submitButton);
// Ожидаем завершения отправки и проверяем аргументы onSuccess
await expect(meta.args?.onSuccess).toHaveBeenCalledWith(12500, 'ACC-998877');
});
},
};
// Сценарий валидации превышения суточного лимита
export const ExceedsLimitError: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const recipientInput = canvas.getByLabelText('Номер счета получателя');
const amountInput = canvas.getByLabelText('Сумма перевода (₽)');
const submitButton = canvas.getByRole('button', { name: 'Подтвердить перевод' });
await step('Ввод суммы, превышающей суточный лимит в 20 000 ₽', async () => {
await userEvent.type(recipientInput, 'ACC-112233');
await userEvent.type(amountInput, '75000');
await userEvent.click(submitButton);
});
await step('Проверка появления сообщения об ошибке валидации', async () => {
const alert = await canvas.findByRole('alert');
await expect(alert).toHaveTextContent('Превышен суточный лимит: 20 000 ₽');
await expect(meta.args?.onSuccess).not.toHaveBeenCalled();
});
},
};@storybook/test-runner и axe-core#Инструмент @storybook/test-runner запускается как независимый CLI-процесс. Он загружает предварительно собранный билд Storybook (или локальный dev-сервер) и последовательно исполняет каждую историю через Playwright.
Файл конфигурации .storybook/test-runner.ts позволяет перехватывать жизненный цикл историй через хуки preVisit и postVisit, внедряя аудит доступности axe-core.
import type { TestRunnerConfig } from '@storybook/test-runner';
import { injectAxe, checkA11y, configureAxe } from 'axe-playwright';
const config: TestRunnerConfig = {
// Хук выполняется перед открытием истории в браузере
async preVisit(page) {
// Внедрение библиотеки axe-core в контекст тестируемой страницы
await injectAxe(page);
},
// Хук выполняется после завершения play-функции истории
async postVisit(page, context) {
// Настройка строгих правил W3C WCAG 2.1 AA
await configureAxe(page, {
rules: [
// Запрет использования низкоконтрастных текстовых узлов
{ id: 'color-contrast', enabled: true },
// Обязательное наличие доступных подписей у интерактивных элементов
{ id: 'button-name', enabled: true },
{ id: 'aria-valid-attr', enabled: true },
],
});
// Запуск аудита доступности для контейнера истории
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true,
},
axeOptions: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
},
});
},
};
export default config;{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build -o storybook-static",
"test:storybook": "test-runner --url http://localhost:6006",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static -p 6006 -s\" \"wait-on tcp:127.0.0.1:6006 && test-runner\""
}
}Storybook 8 поддерживает концепцию Portable Stories с помощью функции composeStories(). Это устраняет необходимость писать отдельные файлы .test.tsx, копирующие верстку и моки из историй: история становится готовым тестом для Vitest.
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { composeStories } from '@storybook/react';
import * as stories from './TransferForm.stories';
// Преобразование всех историй файла в исполняемые React-компоненты
const { SuccessfulTransfer, ExceedsLimitError } = composeStories(stories);
describe('Интеграционные тесты TransferForm на базе Storybook историй', () => {
it('исполняет play-функцию успешного перевода внутри среды Vitest', async () => {
const onSuccessMock = vi.fn();
const { container } = render(<SuccessfulTransfer onSuccess={onSuccessMock} />);
// Вызов скомпонованной play-функции истории внутри виртуального DOM
await SuccessfulTransfer.play({ canvasElement: container });
expect(onSuccessMock).toHaveBeenCalledTimes(1);
expect(onSuccessMock).toHaveBeenCalledWith(12500, 'ACC-998877');
});
it('отображает ошибку валидации при превышении лимита', async () => {
const { container } = render(<ExceedsLimitError />);
await ExceedsLimitError.play({ canvasElement: container });
const alertElement = await screen.findByRole('alert');
expect(alertElement).toBeInTheDocument();
});
});Каким образом связка @storybook/test-runner и axe-core автоматизирует контроль доступности (a11y) в CI/CD?
Использование методов screen.getByRole() внутри play() функции ломает изоляцию: если на экране одновременно отрендерены декораторы Storybook, тулбар или соседние элементы, поиск найдет элементы вне тестируемого компонента либо завершится с ошибкой множественного соответствия.
// ❌ Ошибка: глобальный поиск по всему документу вкладки
play: async () => {
const btn = screen.getByRole('button', { name: 'Сохранить' });
await userEvent.click(btn);
}
// ✅ Правильно: строгая привязка к корневому контейнеру canvasElement
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const btn = canvas.getByRole('button', { name: 'Сохранить' });
await userEvent.click(btn);
}Методы симуляции событий @storybook/test являются строго асинхронными. Пропуск оператора await приводит к тому, что микротаски событийного цикла исполняются после завершения истории, порождая гонки условий (Race Conditions) и ложноположительные результаты.
// ❌ Ошибка: событие клика отправлено синхронно без ожидания
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
userEvent.click(canvas.getByRole('button')); // Нет await!
expect(canvas.getByText('Успех')).toBeInTheDocument(); // Упадет: стейт еще не обновился
}
// ✅ Правильно: все взаимодействия ожидаются последовательно
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(await canvas.findByText('Успех')).toBeInTheDocument();
}Создание параллельных моков для Vitest-тестов и Storybook-историй нарушает принцип DRY: при изменении контракта компонента правки вносятся только в одно место, оставляя тесты устаревшими.
// ❌ Антипаттерн: повторное ручное объявление пропсов в отдельном test-файле
it('renders user card', () => {
render(<UserCard name="Иван" role="Admin" avatar="/img.png" permissions={[...]} />);
});
// ✅ Правильно: переиспользование готовой истории через composeStories
import { composeStories } from '@storybook/react';
import * as stories from './UserCard.stories';
const { AdminUser } = composeStories(stories);
it('renders user card', () => {
render(<AdminUser />);
});