Couple of months ago at work, we thought about a testing solution for handling the testability of our common react components being used in many different applications. While it might be trivial these days achieving it with Storybook (using vitest or even playwright ct library), working with legacy Storybook v7, I found out that it might require some tweaking. The annoying part was actually getting it working both in Storybook's interactions tab (which is a storybook addon) and in the CLI as in almost any common test runner.
When you're stuck on Storybook v7 (often due to enterprise constraints), setting up automated interaction testing becomes a bit more complex than the modern v8+ experience. Some of the challenges I faced were limited tooling compatibility, some extra configurations, error messages and debugging, and more.
The goal was simple: execute the interaction tests defined in play
functions directly from the CLI or from Storybook's UI.
The Component Context: Complex Interactions Need Complex Testing
To understand why this testing setup was so crucial, let me briefly introduce the component that drove this need. A Suggestion component which is a common component in many applications, and has a lot of different states and interactions.
- Multi-step user flows (type → filter → select → validate)
- Keyboard navigation through options with proper focus management
- Dynamic option management (add, edit, delete suggestions in real-time)
- Complex validation states with async feedback
- Multiple selection modes with chip management
- Loading states during async operations
Before finding the solution, I tried several approaches that are commonly recommended but fail with v7:
Attempt 1: @storybook/addon-vitest
npm install @storybook/addon-vitest
I cannot emphasize on how vitest is a quality of life game changer in terms of performance, therefore it was my first choice, but this addon is designed for Storybook v8+ and caused immediate startup crashes. Sadly the addon expects internal APIs that don't exist in v7's architecture.
Attempt 2: Newer @storybook/test-runner Versions
npm install @storybook/test-runner@latest
Runtime errors and compatibility issues. The newer versions expect v8 features and fail with cryptic Playwright errors (which is its dependency).
After extensive trial and error, I found the sweet spot: @storybook/test-runner version 0.13.0. This specific version is the last one with solid Storybook v7 compatibility.
Complete Installation Steps
- Install the test runner and dependencies:
yarn add -D @storybook/test-runner@0.13.0
yarn add -D @storybook/addon-interactions@^7.6.0
yarn add -D @storybook/jest@^0.2.0
yarn add -D @storybook/testing-library@^0.2.0
- Add the script to package.json:
{
"scripts": {
"test:component": "test-storybook"
}
}
- Eject the configuration (critical step):
yarn test:component --eject
Why This Version Matters
Version 0.13.0 is crucial because:
- Last v7-compatible release before the v8 transition
- Stable Playwright integration without modern API dependencies
- Proven Jest configuration that works with v7's architecture
- Mature error handling for complex interaction scenarios
- No dependency on internal v8 APIs that don't exist in v7
Because the default configuration doesn't work out of the box, you must eject and customize:
This creates the essential test-runner-jest.config.js
:
const { getJestConfig } = require('@storybook/test-runner')
/**
* @type {import('@jest/types').Config.InitialOptions}
*/
module.exports = {
// The default configuration comes from @storybook/test-runner
...getJestConfig(),
/** Add your own overrides below
* @see https://jestjs.io/docs/configuration
*/
testEnvironmentOptions: {
'jest-playwright': {
collectCoverage: true,
contextOptions: {},
exitOnPageError: false, // Critical for preventing non-critical page errors from failing tests
},
},
}
Without this configuration, you'll encounter the infamous error:
TypeError: Cannot read properties of undefined (reading 'goto')
This happens because the Playwright page
object isn't properly initialized in the default v7 environment setup.
Essential Storybook Configuration
Your .storybook/main.ts
must include the interactions addon:
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
stories: [
'../src/**/*.stories.mdx',
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-docs',
'@storybook/addon-interactions', // ← Critical for interaction testing
],
framework: {
name: '@storybook/react-vite', // or '@storybook/react-webpack5'
options: {},
},
}
export default config
Required Dependencies
Your package.json
needs these exact versions:
{
"devDependencies": {
"@storybook/addon-interactions": "^7.6.0",
"@storybook/jest": "^0.2.0",
"@storybook/test-runner": "0.13.0",
"@storybook/testing-library": "^0.2.0"
}
}
Version compatibility is critical:
@storybook/test-runner@0.13.0
- Last v7-compatible version@storybook/jest@0.2.0
- Provides Jest matchers for Storybook@storybook/testing-library@0.2.0
- Testing utilities for interactions
Story File Setup
Your story files need proper imports for interaction testing:
import type { Meta, StoryObj } from '@storybook/react'
import {
configure,
screen,
within,
userEvent,
} from '@storybook/testing-library'
import { expect } from '@storybook/jest'
// Configure testing library
configure({ testIdAttribute: 'data-testid' })
// Your component and story setup
const meta: Meta<typeof YourComponent> = {
title: 'Components/YourComponent',
component: YourComponent,
}
export default meta
type Story = StoryObj<typeof YourComponent>
How the Test Runner Works Under the Hood
Understanding the mechanics helps debug issues and optimize performance:
- Browser Automation: Uses Playwright to launch a headless browser (Chromium by default)
- Story Navigation: Automatically discovers and navigates to each story's dedicated URL
- Test Execution:
- Runs a basic "smoke test" for every story (ensures rendering without errors) - By default.
- Executes
play
function interactions for stories that have them
- Result Aggregation: Collects results from all tests and reports them to the CLI
Writing Interaction Tests
Here's the complete test structure that handles all the complex scenarios:
export const InteractionTests: StoryObj<typeof InteractionTestComponent> = {
render: () => <InteractionTestComponent />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement)
await step('Interactive Typing Test', async () => {
const wrapper = await canvas.findByTestId('default-suggestion-input')
const input = within(wrapper).getByRole('textbox')
await userEvent.clear(input)
await userEvent.type(input, 'Marg')
await expect(input).toHaveValue('Marg')
})
await step('Keyboard Navigation Test', async () => {
const wrapper = await canvas.findByTestId('default-suggestion-input')
const input = within(wrapper).getByRole('textbox')
await userEvent.click(input)
await expect(input).toHaveFocus()
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowUp}')
await userEvent.keyboard('{Enter}')
await expect(input).toHaveFocus()
})
await step('Multiple Selection Test', async () => {
const wrapper = await canvas.findByTestId(
'states-suggestion-multiple-input',
)
const input = within(wrapper).getByRole('textbox')
await userEvent.click(input)
await userEvent.type(input, 'React')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{Enter}')
await userEvent.type(input, 'Vue')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{Enter}')
const reactChip = await canvas.findByTestId(
'states-suggestion-multiple-input.tag.react',
)
const vueChip = await canvas.findByTestId(
'states-suggestion-multiple-input.tag.vue',
)
expect(reactChip).toBeInTheDocument()
expect(vueChip).toBeInTheDocument()
})
await step('Component States Test', async () => {
const loadingWrapper = await canvas.findByTestId(
'loading-state-suggestion',
)
const progress = within(loadingWrapper).getByRole('progressbar')
expect(progress).toBeInTheDocument()
const errorWrapper = await canvas.findByTestId('error-state-suggestion')
const warningIcon = within(errorWrapper).getByText('warning')
expect(warningIcon).toBeInTheDocument()
})
await step('Accessibility Test', async () => {
const wrapper = await canvas.findByTestId('default-suggestion-input')
const input = within(wrapper).getByRole('textbox')
expect(input).toHaveAttribute('aria-autocomplete', 'list')
const label = await canvas.findByText('Default Suggestion')
expect(label).toBeInTheDocument()
})
},
}
The Execution Workflow: Two-Terminal Setup
The testing workflow requires careful orchestration:
# Terminal 1: Start Storybook (and keep it running)
yarn storybook
# Wait for "Local: http://localhost:6006/"
# Terminal 2: Run the tests
yarn test:component
Critical timing: The test runner expects Storybook to be fully loaded before starting. Rushing this step causes connection errors.
Debugging Test Failures in Storybook v7
Storybook v7's error messages can be overwhelming. Here's a real failure example and how to decode it (Just changed the role to a wrong role for the sake of the example):
FAIL browser: chromium src/schemaRender/stories/base/suggestion/index.stories.tsx
Development/Base/Suggestion
InteractionTests
✕ play-test (617 ms)
● Development/Base/Suggestion › InteractionTests › play-test
page.evaluate: StorybookTestRunnerError:
An error occurred in the following story. Access the link for full output:
http://127.0.0.1:6006/?path=/story/development-base-suggestion--interaction-tests&addonPanel=storybook/interactions/panel
Message:
Unable to find an accessible element with the role "textbo"
Here are the accessible roles:
textbox:
Name "":
<input
id="someidinput"
placeholder=""
type="text"
value="Pizza"
/>
Debugging Strategy:
- Use the provided URL: The error includes a direct link to the failing story
- Check element selectors: The error shows available roles - use these for your queries
- Verify timing: Often failures are due to elements not being ready
- Test manually first: Run the interaction manually in Storybook before automating
The Screen vs Canvas Problem
A common mistake in Storybook v7 interaction tests:
// ❌ Wrong - causes warnings
const element = screen.getByRole('textbox')
// ✅ Correct - use canvas
const canvas = within(canvasElement)
const element = canvas.getByRole('textbox')
The screen
object works but generates warnings about Testing Library best practices.
Storybook v7 vs v8: What You're Missing
If you're stuck on v7, here's what v8+ offers that you can't easily replicate:
- Built-in Vitest integration with
@storybook/addon-vitest
- Improved error messages with better stack traces
- Faster test execution through optimized browser handling
- Better TypeScript support in test configurations
- Streamlined setup with working defaults
But with the setup I've outlined, you can achieve 90% of the functionality with v7.
So what we achieved here?
Automated Test Coverage
- Interaction testing for complex user flows
- Accessibility validation built into every test
- Regression prevention through CI integration
- Visual testing combined with functional testing
Development Benefits
- Faster debugging with direct Storybook links in failures
- Confident refactoring knowing interactions are covered
- Documentation through tests showing expected behavior
- Onboarding acceleration for new team members
Integrating with CI/CD
For production use, add this to your CI pipeline:
# .github/workflows/storybook-tests.yml
name: Storybook Tests
on: [push, pull_request]
jobs:
interaction-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build Storybook
run: yarn build-storybook --quiet
- name: Serve Storybook and run tests
run: |
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on http://127.0.0.1:6006 && yarn test:component"
Best Practices for Storybook v7 Testing
1. Test Structure
// ✅ Good: Clear steps with descriptive names
await step(
'User can select multiple options with keyboard navigation',
async () => {
// Test implementation
}
)
// ❌ Bad: Vague step descriptions
await step('Test selection', async () => {
// Test implementation
})
2. Element Selection
// ✅ Good: Use data-testid attributes
const input = canvas.getByTestId('suggestion-input')
// ❌ Bad: Fragile CSS selectors
const input = canvas.querySelector('.someCssClass')
3. Async Handling
// ✅ Good: Proper async waiting
await userEvent.type(input, 'search term')
await waitFor(() => expect(dropdown).toBeVisible())
// ❌ Bad: Not waiting for async operations
userEvent.type(input, 'search term')
expect(dropdown).toBeVisible() // Might fail due to timing
That's it. I would love to know and hear if you have any other tips or tricks for Storybook v7 testing or even better solutions for such cases.