storybook-play-functions
$
npx mdskill add TheBushidoCollective/han/storybook-play-functionsAutomate component interaction testing within Storybook stories to validate user flows and state changes.
- Verifies component behavior and simulates user actions directly within storybook stories.
- Integrates with Storybook's testing utilities, including `@storybook/test` and Testing Library.
- Executes predefined asynchronous play functions after a story renders to test interactions.
- Provides assertions and simulated user events, confirming component responsiveness to inputs.
SKILL.md
.github/skills/storybook-play-functionsView on GitHub ↗
---
name: storybook-play-functions
user-invocable: false
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:
- Read
- Write
- Edit
- Bash
- Grep
- Glob
---
# Storybook - Play Functions
Write automated interaction tests within stories using play functions to verify component behavior, simulate user actions, and test edge cases.
## Key Concepts
### Play Functions
Play functions run after a story renders, allowing you to simulate user interactions:
```typescript
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();
},
};
```
### Testing Library Integration
Storybook integrates with Testing Library for queries and interactions:
- `within(canvasElement)` - Scopes queries to the story
- `userEvent` - Simulates realistic user interactions
- `expect` - Jest-compatible assertions
- `waitFor` - Waits for async changes
### Test Execution
Play functions execute:
- When viewing a story in Storybook
- During visual regression testing
- In test runners for automated testing
- On story hot-reload during development
## Best Practices
### 1. Use Testing Library Queries
Use semantic queries to find elements:
```typescript
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();
},
};
```
### 2. Simulate Realistic User Behavior
Use `userEvent` for realistic interactions:
```typescript
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 }));
},
};
```
### 3. Test Async Behavior
Use `waitFor` for async state changes:
```typescript
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 }
);
},
};
```
### 4. Test Error States
Validate error handling and validation:
```typescript
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();
},
};
```
### 5. Compose Complex Scenarios
Break complex interactions into steps:
```typescript
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();
});
},
};
```
## Common Patterns
### Modal Interactions
```typescript
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();
});
},
};
```
### Keyboard Navigation
```typescript
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();
});
},
};
```
### Multi-Step Forms
```typescript
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();
});
},
};
```
### Drag and Drop
```typescript
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();
},
};
```
### File Upload
```typescript
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();
},
};
```
## Advanced Patterns
### Reusable Play Functions
```typescript
// 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();
},
};
```
### Step-Through Testing
```typescript
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();
});
},
};
```
## Anti-Patterns
### ❌ Don't Use Direct DOM Manipulation
```typescript
// Bad
export const Bad: Story = {
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector('input');
input.value = 'text';
input.dispatchEvent(new Event('input'));
},
};
```
```typescript
// Good
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'text');
},
};
```
### ❌ Don't Forget Async/Await
```typescript
// 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();
},
};
```
```typescript
// Good
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Clicked')).toBeInTheDocument();
},
};
```
### ❌ Don't Use Brittle Selectors
```typescript
// Bad - Fragile selectors
export const Bad: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Submit')); // Breaks if text changes
},
};
```
```typescript
// Good - Semantic selectors
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
},
};
```
## Related Skills
- **storybook-story-writing**: Creating stories to test with play functions
- **storybook-args-controls**: Using args to test different component states
- **storybook-configuration**: Setting up test runner for automated testing
More from TheBushidoCollective/han
- absinthe-resolversUse when implementing GraphQL resolvers with Absinthe. Covers resolver patterns, dataloader integration, batching, and error handling.
- absinthe-schemaUse when designing GraphQL schemas with Absinthe. Covers type definitions, interfaces, unions, enums, and schema organization patterns.
- absinthe-subscriptionsUse when implementing real-time GraphQL subscriptions with Absinthe. Covers Phoenix channels, PubSub, and subscription patterns.
- act-docker-setupUse when configuring Docker environments for act, selecting runner images, managing container resources, or troubleshooting Docker-related issues with local GitHub Actions testing.
- act-local-testingUse when testing GitHub Actions workflows locally with act. Covers act CLI usage, Docker configuration, debugging workflows, and troubleshooting common issues when running workflows on your local machine.
- act-workflow-syntaxUse when creating or modifying GitHub Actions workflow files. Provides guidance on workflow syntax, triggers, jobs, steps, and expressions for creating valid GitHub Actions workflows that can be tested locally with act.
- ameba-configurationUse when configuring Ameba rules and settings for Crystal projects including .ameba.yml setup, rule management, severity levels, and code quality enforcement.
- ameba-custom-rulesUse when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing.
- ameba-integrationUse when integrating Ameba into development workflows including CI/CD pipelines, pre-commit hooks, GitHub Actions, and automated code review processes.
- analyze-performanceAnalyze performance metrics and identify slow transactions in Sentry