| name | storybook-play-testing |
| description | Storybook play function integration testing for TanStack Start/Router apps. Covers Vite config for blocking server code, router/query/theme decorators, state mocking strategies (props, collections, React Query cache, actor-kit), play function patterns, viewport coverage, and vitest browser test runner. Use when writing Storybook stories, setting up Storybook in a TanStack Start project, testing route-level pages, or adding play function interaction tests.
|
| dependsOn | ["jonmumm/skills@actorkit-storybook-testing","jonmumm/skills@mutation-testing"] |
Storybook Play Function Testing
Integration tests that render real components in a real browser. No mocks of what you own — mock only the server boundary, then test real behavior through real DOM interactions.
When to Use
- Setting up Storybook in a TanStack Start/Router project
- Writing stories for route-level pages or shared components
- Adding play function interaction tests
- Testing form flows, state transitions, edge cases
- Verifying responsive behavior (desktop + mobile)
The Problem: TanStack Start + Storybook
TanStack Start has SSR, server functions (createServerFn), Cloudflare Workers entry points, and a generated route tree — all of which crash in Storybook's browser-only preview.
Setup
Using the published addon (recommended)
pnpm add -D storybook-addon-tanstack-start
This single package handles everything: server-side import stubbing, TanStack/Nitro plugin stripping, router context, route params, search params, and loader data.
import { tanstackStartPlugin } from 'storybook-addon-tanstack-start/plugin'
import { mergeConfig } from 'vite'
const config = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-themes',
],
framework: { name: '@storybook/react-vite', options: {} },
async viteFinal(config) {
return mergeConfig(config, {
plugins: [tanstackStartPlugin({
additionalServerModules: ['~/lib/auth/server', '~/lib/billing/server'],
})],
})
},
}
export default config
export { decorators } from 'storybook-addon-tanstack-start/preview'
For app-specific server modules, alias them to mock files:
export async function loginWithPassword({ data }: LoginInput) {
if (data.email === 'test@example.com' && data.password === 'password123')
return { ok: true as const, role: 'parent' as const }
return { ok: false as const, error: 'Invalid email or password' }
}
'~/lib/auth/server': path.resolve(__dirname, 'mocks/auth-server.ts'),
4. Query Decorator (.storybook/decorators/QueryDecorator.tsx)
Provides React Query context with optional cache pre-population:
import type { Decorator } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
export const withQuery: Decorator = (Story, context) => {
const queryConfig = context.parameters?.query || {}
const queryClient = React.useMemo(() => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
...queryConfig.options?.queries,
...(queryConfig.enabled === false && { enabled: false }),
},
mutations: { retry: false, ...queryConfig.options?.mutations },
},
})
if (queryConfig.mockData) {
Object.entries(queryConfig.mockData).forEach(([key, data]) => {
const queryKey = Array.isArray(key) ? key : JSON.parse(key)
client.setQueryData(queryKey, data)
})
}
return client
}, [queryConfig])
return (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
)
}
5. Preview (.storybook/preview.ts)
Wire decorators in correct order — outermost first. The router decorator comes
from the addon; the Query decorator is custom (if your app uses React Query):
import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/react'
import { decorators as routerDecorators } from 'storybook-addon-tanstack-start/preview'
import { withQuery } from './decorators/QueryDecorator'
import '../src/styles.css'
const preview: Preview = {
initialGlobals: {
viewport: { value: 'desktop', isRotated: false },
},
decorators: [
withQuery,
...routerDecorators,
withThemeByClassName({
themes: { light: 'light', dark: 'dark' },
defaultTheme: 'light',
parentSelector: 'html',
}),
],
parameters: {
viewport: {
options: {
mobileSmall: { name: 'Mobile Small (320px)', styles: { width: '320px', height: '568px' } },
mobileLarge: { name: 'Mobile Large (414px)', styles: { width: '414px', height: '896px' } },
tablet: { name: 'Tablet (768px)', styles: { width: '768px', height: '1024px' } },
desktop: { name: 'Desktop (1280px)', styles: { width: '1280px', height: '800px' } },
},
},
},
}
export default preview
6. Vitest Browser Testing (vitest.config.ts)
Run play functions as real browser tests via Playwright:
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [
react(),
storybookTest({
configDir: '.storybook',
storybookScript: 'pnpm storybook --ci',
tags: {
include: ['test'],
exclude: ['no-tests'],
},
}),
],
test: {
name: 'storybook',
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
setupFiles: ['./.storybook/vitest.setup.ts'],
globals: true,
},
})
Dependencies
pnpm add -D storybook-addon-tanstack-start \
@storybook/react @storybook/react-vite @storybook/builder-vite \
@storybook/addon-vitest @storybook/addon-a11y @storybook/addon-docs \
@storybook/addon-themes storybook \
@vitest/browser playwright
State Mocking Strategies
Different state management requires different mocking approaches. Pick the one that matches your app:
Strategy 1: Props (simplest)
For presentational components that receive data as props:
const meta: Meta<typeof AdminDashboard> = {
component: AdminDashboard,
tags: ['autodocs'],
}
export const Desktop: Story = {
args: {
kids: mockKids,
totalLiability: 43750,
pendingRequests: mockPendingRequests,
onApproveRequest: fn(),
onDenyRequest: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByText('Total Liability')).toBeVisible()
},
}
When to use: Components that are already well-composed — they take data in, render it, and emit callbacks. This is the ideal target shape.
Strategy 2: Storybook Loaders + In-Memory Collections
For apps using TanStack React DB, Zustand, nanostores, or any client-side store:
import { appStateCollection, kidProfilesCollection } from '~/lib/kid-store'
const seedData = async () => {
appStateCollection.insert({ id: 'app-state', activeProfileId: 'kid-1', globalRate: 500 })
kidProfilesCollection.insert({
id: 'kid-1', name: 'Avery', type: 'CHILD',
payAmount: 2500, payFrequency: 'WEEKLY',
nextPayoutDate: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
})
return {}
}
export const Desktop: Story = {
loaders: [seedData],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByText('Current Balance')).toBeVisible()
},
}
When to use: Components that read from client-side stores via hooks. The loader seeds the store before the story renders. No network calls happen — collections live in memory.
Strategy 3: React Query Cache Pre-Population
For components that use useQuery / useSuspenseQuery:
export const WithData: Story = {
parameters: {
query: {
mockData: {
'["posts"]': [{ id: 1, title: 'First Post' }],
'["user","123"]': { id: '123', name: 'John' },
},
},
},
}
export const Loading: Story = {
parameters: { query: { enabled: false } },
}
When to use: Components that fetch data via React Query hooks.
Strategy 4: Actor-Kit Mock Clients
For components consuming actor-kit contexts (see /actorkit-storybook-testing skill for full details):
import { createActorKitMockClient } from 'actor-kit/test'
import { withActorKit } from 'actor-kit/storybook'
const meta: Meta<typeof GameView> = {
decorators: [
withActorKit<GameMachine>({ actorType: 'game', context: GameContext }),
],
}
export const Lobby: Story = {
parameters: {
actorKit: {
game: { 'game-123': { ...defaultGameSnapshot, value: { lobby: 'ready' } } },
},
},
}
export const StartGame: Story = {
play: async ({ mount, canvas }) => {
const client = createActorKitMockClient<GameMachine>({ initialSnapshot: defaultGameSnapshot })
client.send = (event) => {
if (event.type === 'START_GAME') {
client.produce((draft) => { draft.value = { active: 'questionPrep' } })
}
return Promise.resolve()
}
await mount(
<GameContext.ProviderFromClient client={client}>
<GameView />
</GameContext.ProviderFromClient>
)
await userEvent.click(canvas.getByRole('button', { name: /start/i }))
await canvas.findByText(/question prep/i)
},
}
Strategy 5: Server Function Mocks
For pages that call createServerFn — create per-domain mock files:
export async function loginWithPassword({ data }: LoginInput) {
if (data.email === 'test@example.com' && data.password === 'password123')
return { ok: true as const, role: 'parent' as const }
return { ok: false as const, error: 'Invalid email or password' }
}
Then alias in main.ts:
'~/lib/auth/server': path.resolve(__dirname, 'mocks/auth-server.ts'),
Play Function Patterns
Basic: Assert Visibility
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByText('Dashboard')).toBeVisible()
await expect(canvas.getByRole('button', { name: /submit/i })).toBeDisabled()
}
Form Interaction
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText('Amount'), '10')
await userEvent.type(canvas.getByLabelText('What is this for?'), 'Art supplies')
const submit = canvas.getByRole('button', { name: /submit/i })
await expect(submit).toBeEnabled()
await userEvent.click(submit)
}
Async Waiting
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await waitFor(() =>
expect(canvas.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
)
}
Labeled Steps
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement)
await step('Fill form', async () => {
await userEvent.type(canvas.getByLabelText('Name'), 'Sarah')
})
await step('Submit', async () => {
await userEvent.click(canvas.getByRole('button', { name: /continue/i }))
})
await step('Verify success', async () => {
await expect(canvas.getByText(/success/i)).toBeVisible()
})
}
Portaled Content (Drawers, Modals, Tooltips)
Content that portals to document.body won't be inside canvasElement:
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'Review' }))
const body = within(document.body)
await expect(body.getByText('Review Withdrawal Request')).toBeVisible()
}
Spy Callbacks
import { fn } from 'storybook/test'
export const ClickHandler: Story = {
args: { onClick: fn() },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button'))
await expect(args.onClick).toHaveBeenCalledTimes(1)
},
}
Viewport Coverage
Every story MUST have Desktop + Mobile variants. This catches responsive bugs early.
export const Desktop: Story = {
args: defaultArgs,
play: async ({ canvasElement }) => { },
}
export const Mobile: Story = {
args: defaultArgs,
globals: { viewport: { value: 'mobileLarge', isRotated: false } },
play: Desktop.play,
}
For stories with mobile-specific assertions, write a separate play function.
Story Organization
Route-Level Pages
Extract the page component from the route file so it can be imported without route registration side effects:
export const Route = createFileRoute('/dashboard/')({
component: DashboardPage,
loader: async () => { },
})
export function DashboardPage() { }
import { DashboardPage } from '~/routes/dashboard/index'
function DashboardWrapper() {
return (
<div className="min-h-screen">
<Navigation currentPath="/dashboard" />
<DashboardPage />
</div>
)
}
const meta: Meta<typeof DashboardWrapper> = {
title: 'Pages/Dashboard',
component: DashboardWrapper,
parameters: { layout: 'fullscreen' },
}
Naming Convention
Components/UI/Button — Shared UI primitives
Components/Navigation — Layout components
Components/AdminDashboard — Feature components
Onboarding/Kid Name — Flow steps
Pages/Kid/Dashboard — Route-level pages
Pages/Admin/Settings — Route-level pages
Mocking Server Functions in Stories
The addon stubs createServerFn generically. For your app's own server modules,
use additionalServerModules in the plugin config to redirect them to stubs,
then create mock files with the same function signatures:
export async function loginWithPassword({ data }: LoginInput) {
if (data.email === 'parent@example.com' && data.password === 'password123')
return { ok: true as const, role: 'parent' as const }
return { ok: false as const, error: 'Invalid email or password' }
}
export async function startOAuthLogin({ data }: OAuthStartInput) {
return { ok: true as const, redirectUrl: `https://oauth.example.com/${data.provider}` }
}
Then alias in .storybook/main.ts:
resolve: {
alias: {
'~/lib/auth/server': path.resolve(__dirname, 'mocks/auth-server.ts'),
'~/lib/billing/server': path.resolve(__dirname, 'mocks/billing-server.ts'),
},
},
Checklist
Before shipping stories:
Storybook MCP for Agent-Driven Development
Storybook 10.3+ includes MCP support that
lets AI agents interact with your stories — preview components, run tests, and self-correct.
npx storybook add @storybook/addon-mcp
npx mcp-add --type http --url "http://localhost:6006/mcp" --scope project
For TanStack Start projects, install storybook-addon-tanstack-start first (makes the
build work), then add MCP on top. For team-wide access, publish via Chromatic:
{
"mcpServers": {
"storybook-mcp": {
"type": "http",
"url": "https://main--<appid>.chromatic.com/mcp"
}
}
}
Related Skills
/actorkit-storybook-testing — Deep patterns for actor-kit mock clients and state machines
/testing-trophy — Philosophy: play functions ARE the integration test layer
/react-composable-components — Components shaped for easy story testing
/dont-use-use-effect — Cleaner React = easier to test
/mutation-testing — Verify play function test quality with Stryker