У класичних процесах розробки інтерфейсів Storybook тривалий час сприймався виключно як каталог компонентів (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 |
+----------------------------------+500 Internal Server Error та порожніх списків без запуску бекенду.aria-label або некоректними семантичними ролями.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() логічно розбиває взаємодію на фази, які наочно відображаються у часовій шкалі панелі Interactions у 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 через 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) {
// Налаштування правил перевірки згідно з 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(). Історії можна імпортувати безпосередньо у файли юніт-тестів без повторного опису верстки чи контекстних провайдерів:
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';
// Перетворення описів історій CSF3 у робочі React-компоненти
const { SuccessfulTransfer, ExceedsLimitError } = composeStories(stories);
describe('Інтеграційні тести TransferForm на основі Portable Stories', () => {
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();
});
});Використання глобальних методів на зразок screen.getByRole() всередині play() руйнує ізоляцію: тест може випадково знайти елементи навігації або панелі керування самого Storybook замість вузлів компонента.
// ❌ ПОМИЛКА: пошук по всьому глобальному DOM вікна
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 повертають Promise. Пропуск await призводить до того, що мікротаски виконуються після завершення життєвого циклу історії, викликаючи умови перегонів та помилкові спрацьовування.
// ❌ ПОМИЛКА: клік відправлено синхронно без очікування
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
userEvent.click(canvas.getByRole('button')); // Відсутній await!
expect(canvas.getByText('Успіх')).toBeInTheDocument(); // Впаде: DOM ще не оновився
}
// ✅ ПРАВИЛЬНО: усі асинхронні виклики очікуються послідовно
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(await canvas.findByText('Успіх')).toBeInTheDocument();
}Створення паралельних наборів мок-даних порушує принцип DRY. При зміні інтерфейсу компонента правки вносяться лише в один файл, через що тести втрачають актуальність.
Рішення: застосовувати composeStories() для безпосереднього імпорту канонічних історій у тести Vitest.
Опис базових, граничних і помилкових станів компонента у файлі CSF 3 через властивості args.
Реалізація користувацьких дій через within(canvasElement), userEvent і послідовні кроки step().
Перевірка таймлайну у вкладці Interactions та аудит доступності у панелі Accessibility в Storybook UI.
Налаштування .storybook/test-runner.ts із викликом checkA11y в хуку postVisit для перевірки WCAG.
Збирання статичного каталогу Storybook та паралельне виконання test-runner у середовищі CI.