Use when adding interaction testing to Storybook stories. Enables automated testing of component behavior, user interactions, and state changes directly in stories.
Limited to specific tools
Additional assets for this skill
This skill is limited to using the following tools:
name: storybook-play-functions description: Use when adding interaction testing to Storybook stories. Enables automated testing of component behavior, user interactions, and state changes directly in stories. allowed-tools:
Write automated interaction tests within stories using play functions to verify component behavior, simulate user actions, and test edge cases.
Play functions run after a story renders, allowing you to simulate user interactions:
import { within, userEvent, expect } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
import { LoginForm } from './LoginForm';
const meta = {
component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(canvas.getByText('Welcome!')).toBeInTheDocument();
},
};
Storybook integrates with Testing Library for queries and interactions:
within(canvasElement) - Scopes queries to the storyuserEvent - Simulates realistic user interactionsexpect - Jest-compatible assertionswaitFor - Waits for async changesPlay functions execute:
Use semantic queries to find elements:
export const SearchFlow: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Good - Semantic queries
const searchInput = canvas.getByRole('searchbox');
const submitButton = canvas.getByRole('button', { name: /search/i });
const results = canvas.getByRole('list', { name: /results/i });
await userEvent.type(searchInput, 'storybook');
await userEvent.click(submitButton);
await expect(results).toBeInTheDocument();
},
};
Use userEvent for realistic interactions:
import { userEvent } from '@storybook/test';
export const FormInteraction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Type naturally with delay
await userEvent.type(canvas.getByLabelText('Name'), 'John Doe', {
delay: 100,
});
// Tab between fields
await userEvent.tab();
// Select from dropdown
await userEvent.selectOptions(
canvas.getByLabelText('Country'),
'United States'
);
// Click submit
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
},
};
Use waitFor for async state changes:
import { waitFor } from '@storybook/test';
export const AsyncData: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /load data/i }));
// Wait for loading state
await waitFor(() => {
expect(canvas.getByText('Loading...')).toBeInTheDocument();
});
// Wait for data to appear
await waitFor(
() => {
expect(canvas.getByRole('list')).toBeInTheDocument();
expect(canvas.getAllByRole('listitem')).toHaveLength(5);
},
{ timeout: 3000 }
);
},
};
Validate error handling and validation:
export const ValidationErrors: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Submit empty form
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Verify error messages
await expect(canvas.getByText('Email is required')).toBeInTheDocument();
await expect(canvas.getByText('Password is required')).toBeInTheDocument();
// Fill only email
await userEvent.type(canvas.getByLabelText('Email'), 'invalid-email');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Verify email validation
await expect(canvas.getByText('Email is invalid')).toBeInTheDocument();
},
};
Break complex interactions into steps:
export const CheckoutFlow: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Step 1: Add items to cart
await userEvent.click(canvas.getByRole('button', { name: /add to cart/i }));
await expect(canvas.getByText('1 item in cart')).toBeInTheDocument();
// Step 2: Proceed to checkout
await userEvent.click(canvas.getByRole('button', { name: /checkout/i }));
await expect(canvas.getByRole('heading', { name: /checkout/i })).toBeInTheDocument();
// Step 3: Fill shipping info
await userEvent.type(canvas.getByLabelText('Address'), '123 Main St');
await userEvent.type(canvas.getByLabelText('City'), 'New York');
await userEvent.selectOptions(canvas.getByLabelText('State'), 'NY');
// Step 4: Submit order
await userEvent.click(canvas.getByRole('button', { name: /place order/i }));
await waitFor(() => {
expect(canvas.getByText('Order confirmed!')).toBeInTheDocument();
});
},
};
export const OpenModal: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Modal not visible initially
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
// Click trigger
await userEvent.click(canvas.getByRole('button', { name: /open/i }));
// Modal appears
const modal = canvas.getByRole('dialog');
await expect(modal).toBeInTheDocument();
// Close modal
await userEvent.click(within(modal).getByRole('button', { name: /close/i }));
// Modal disappears
await waitFor(() => {
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
});
},
};
export const KeyboardNav: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstItem = canvas.getAllByRole('menuitem')[0];
firstItem.focus();
// Navigate with arrow keys
await userEvent.keyboard('{ArrowDown}');
await expect(canvas.getAllByRole('menuitem')[1]).toHaveFocus();
await userEvent.keyboard('{ArrowDown}');
await expect(canvas.getAllByRole('menuitem')[2]).toHaveFocus();
// Select with Enter
await userEvent.keyboard('{Enter}');
await expect(canvas.getByText('Item 3 selected')).toBeInTheDocument();
// Close with Escape
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(canvas.queryByRole('menu')).not.toBeInTheDocument();
});
},
};
export const Wizard: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Step 1
await userEvent.type(canvas.getByLabelText('First Name'), 'John');
await userEvent.type(canvas.getByLabelText('Last Name'), 'Doe');
await userEvent.click(canvas.getByRole('button', { name: /next/i }));
// Step 2
await expect(canvas.getByText('Step 2 of 3')).toBeInTheDocument();
await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
await userEvent.click(canvas.getByRole('button', { name: /next/i }));
// Step 3
await expect(canvas.getByText('Step 3 of 3')).toBeInTheDocument();
await userEvent.click(canvas.getByRole('checkbox', { name: /agree/i }));
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Success
await waitFor(() => {
expect(canvas.getByText('Registration complete!')).toBeInTheDocument();
});
},
};
export const DragDrop: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const draggable = canvas.getByRole('button', { name: /drag me/i });
const dropzone = canvas.getByRole('region', { name: /drop zone/i });
// Perform drag and drop
await userEvent.pointer([
{ keys: '[MouseLeft>]', target: draggable },
{ coords: { x: 100, y: 100 } },
{ target: dropzone },
{ keys: '[/MouseLeft]' },
]);
await expect(canvas.getByText('Item dropped!')).toBeInTheDocument();
},
};
export const FileUpload: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const input = canvas.getByLabelText('Upload file');
await userEvent.upload(input, file);
await expect(canvas.getByText('test.txt')).toBeInTheDocument();
await expect(canvas.getByText('1 file selected')).toBeInTheDocument();
},
};
// helpers.ts
export async function login(canvas: ReturnType<typeof within>) {
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /login/i }));
}
// Story.stories.tsx
export const AfterLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await login(canvas);
// Test authenticated state
await expect(canvas.getByText('Welcome, User!')).toBeInTheDocument();
},
};
import { step } from '@storybook/test';
export const MultiStep: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await step('Fill in personal info', async () => {
await userEvent.type(canvas.getByLabelText('Name'), 'John Doe');
await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
});
await step('Select preferences', async () => {
await userEvent.click(canvas.getByLabelText('Subscribe to newsletter'));
await userEvent.selectOptions(canvas.getByLabelText('Theme'), 'dark');
});
await step('Submit form', async () => {
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(canvas.getByText('Success!')).toBeInTheDocument();
});
},
};
// Bad
export const Bad: Story = {
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector('input');
input.value = 'text';
input.dispatchEvent(new Event('input'));
},
};
// Good
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'text');
},
};
// Bad - Missing await
export const Bad: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
userEvent.click(canvas.getByRole('button')); // Won't work!
expect(canvas.getByText('Clicked')).toBeInTheDocument();
},
};
// Good
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Clicked')).toBeInTheDocument();
},
};
// Bad - Fragile selectors
export const Bad: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Submit')); // Breaks if text changes
},
};
// Good - Semantic selectors
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
},
};