Stories & Galleries — The New Component Testing
Playwright 1.62 reinvents component testing. Stories wrap components in scenarios, galleries render them on demand, and fixtures.mount() returns a Locator scoped to the story.
📖 The Story: The 200 Mount Calls
A team had 200 component tests for a design system. Each test called mount(Button, { props: { variant: 'primary', size: 'lg', disabled: false } }) and asserted the rendered output. The tests worked, but every time the Button component’s API changed (new prop, renamed variant), the team had to update 200 mount calls across 50 test files.
Worse, the same scenario was tested in multiple files. The “primary disabled button” scenario appeared in 8 tests — each with its own slightly different mount call. When the disabled state’s visual style changed, all 8 tests needed screenshot updates.
The team tried extracting mount calls into shared fixtures, but the fixtures were untyped — a typo in { variant: 'primry' } wouldn’t be caught until the test ran.
Stories and galleries fix this. Each scenario lives in one story file, tests reference it by ID, and props are type-checked at compile time.
🎯 Why It Matters
Reusable
Each scenario is a story, defined once and referenced by ID across tests. No more duplicate mount calls.
Typed
Pass a story type as a template argument to type-check props at compile time. Typos caught before runtime.
Gallery
A gallery page renders stories on demand. Visual review all scenarios in one place — like Storybook.
Scoped Locator
mount() returns a Locator scoped to the story’s root element. Interact and assert without global selectors.
🌍 Real World Example
A design system team has 50 components, each with 5–10 scenarios (variants, states, sizes). Before 1.62, they had 500 mount calls across 200 test files. After migrating to stories and galleries, they have 500 story files (one per scenario) and 200 tests that reference stories by ID. When a component’s API changes, they update the story file once — all tests that reference it pick up the change automatically.
🧒 Explain Like I'm 10
Imagine you’re a toy tester. Before, every time you wanted to test a toy car, you had to build it from scratch: pick the color, pick the wheels, pick the size. You did this in every test, and sometimes you picked the wrong color by mistake.
Now, you have a catalog of pre-built toy cars: “Red Car with Big Wheels”, “Blue Car with Small Wheels”. Each catalog entry is a “story”. When you want to test, you just say “give me the Red Car with Big Wheels” — it arrives pre-built, and you can’t accidentally pick the wrong color.
🎓 Professional Explanation
Playwright’s component testing (CT) lets you mount a single component in isolation and test it without a full app. Before 1.62, you called mount(Component, { props }) directly in each test — props were inline and untyped, and the same scenario had to be re-mounted in every test that needed it.
Playwright 1.62 introduces a stories-and-galleries model. A story wraps a component in one specific scenario — hard-coded props, mock data, providers. A gallery is a page that serves stories on demand. The new fixtures.mount() fixture navigates to the gallery, mounts a story by ID, and returns a Locator scoped to the story’s root element.
📊 The Flow
The Stories-and-Galleries Architecture
⚙️ The Stories & Galleries API
The new fixtures.mount(storyId) fixture navigates to the gallery, mounts the story, and returns a Locator. Pass a story type as a template argument to type-check props. Use update(props) and unmount() on the returned locator to re-render or tear down.
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'click should expand', async ({ mount }) => { class=class="tk-str">"tk-com">// Mount a story by ID — returns a Locator scoped to the story root const component = await mount(class="tk-str">'components/Expandable/Stateful'); await component.getByRole(class="tk-str">'button').click(); await expect(component.getByTestId(class="tk-str">'expanded')).toHaveValue(class="tk-str">'true'); });
mount(storyId) navigates to the gallery, mounts the story with the given ID, and returns a Locator scoped to the story’s root element. You interact with the component via this Locator — component.getByRole('button') is scoped to the story, not the whole page.
class=class="tk-str">"tk-com">// Type-checked props via template argument import { test } from class="tk-str">'@playwright/test'; import type { ExpandableStory } from class="tk-str">'./stories/Expandable'; test(class="tk-str">'type-checked props', async ({ mount }) => { class=class="tk-str">"tk-com">// Pass the story type as a template argument const component = await mount<ExpandableStory>( class="tk-str">'components/Expandable/Stateful' ); class=class="tk-str">"tk-com">// Re-render with new props mid-test await component.update({ initiallyOpen: true }); await expect(component.getByTestId(class="tk-str">'expanded')).toHaveValue(class="tk-str">'true'); class=class="tk-str">"tk-com">// Tear down explicitly (otherwise auto-cleanup at test end) await component.unmount(); });
mount<StoryType>(storyId) type-checks the story’s props at compile time. update(props) re-renders the story with new props without re-mounting. unmount() tears down the story explicitly (auto-cleanup also happens at test end).
class=class="tk-str">"tk-com">// Story file: components/Expandable/Stateful.story.tsx import { defineStory } from class="tk-str">'@playwright/test'; import { Expandable } from class="tk-str">'./Expandable'; export default defineStory({ component: Expandable, props: { initiallyOpen: false, title: class="tk-str">'Click to expand', }, class=class="tk-str">"tk-com">// Mock data, providers, etc. providers: [ class=class="tk-str">"tk-com">// [ThemeProvider, { theme: class="tk-str">'dark' }] ], }); class=class="tk-str">"tk-com">// Gallery: components/gallery.html class=class="tk-str">"tk-com">// Serves stories on demand by ID — Playwright generates this automatically
A story file defines one scenario: the component, hard-coded props, and any providers (theme, router, state). The gallery is an HTML page that Playwright generates automatically — it serves stories by ID when mount() is called.
🔄 Before vs After
Before: Inline mount with untyped props (1.61 and earlier)
import { test } from class="tk-str">'@playwright/test'; import { Expandable } from class="tk-str">'./Expandable'; test(class="tk-str">'click should expand', async ({ mount }) => { class=class="tk-str">"tk-com">// Inline props — untyped, duplicated across tests const component = await mount(Expandable, { props: { initiallyOpen: false, title: class="tk-str">'Click to expand', }, }); await component.getByRole(class="tk-str">'button').click(); await expect(component.getByTestId(class="tk-str">'expanded')).toHaveValue(class="tk-str">'true'); });
Every test re-specifies the props inline. A typo in initiallyOpen isn’t caught until runtime. The same scenario is duplicated across tests, so a props change requires updating every test.
After: Stories & galleries (1.62+)
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'click should expand', async ({ mount }) => { class=class="tk-str">"tk-com">// Mount a story by ID — props come from the story file const component = await mount(class="tk-str">'components/Expandable/Stateful'); await component.getByRole(class="tk-str">'button').click(); await expect(component.getByTestId(class="tk-str">'expanded')).toHaveValue(class="tk-str">'true'); });
Props live in the story file, not the test. Tests reference stories by ID — clean, DRY, and type-checked. When the component’s API changes, update the story file once.
Migration from 1.61 to 1.62 is breaking for existing CT tests. The mount(Component, { props }) signature is replaced by mount(storyId). Existing @playwright/experimental-ct-react, -vue, and -svelte tests will not run unchanged. Microsoft provides a codemod for the mechanical migration, but you’ll still need to author stories for each scenario you previously mounted inline.
⚠️ Common Mistakes
Mistake 1: Not running the codemod before upgrading
The mount() signature changed. Existing CT tests will fail with ‘mount expects a string, got a component’. Run npx playwright ct-migrate first, then author stories for any inline mount calls the codemod couldn’t handle.
Mistake 2: Putting business logic in stories
Stories are for component configuration (props, providers, mocks) — not test logic. If you find yourself writing assertions or interactions in a story file, move them to the test. Stories should be declarative.
Mistake 3: Forgetting to update() when testing state changes
If your test changes the component’s props mid-test (e.g., toggling disabled), use component.update({ disabled: true }). Re-mounting with mount() creates a fresh instance and loses any state.
✅ Best Practices
- One story per scenario — if two tests need the same props, they should reference the same story.
- Keep stories declarative — props, providers, mocks only. No assertions or interactions.
- Use
mount<StoryType>()for compile-time prop type-checking. - Use
update()for mid-test prop changes — don’t re-mount. - Run the codemod first when migrating from 1.61 — it handles 80% of the mechanical changes.
🏗️ Senior Engineer Deep Dive
Stories vs Storybook stories
Playwright stories are conceptually similar to Storybook stories — each is a component rendered in a fixed scenario. But Playwright stories are designed for testing, not visual review. They integrate with the test runner via mount(storyId), return a Locator for interaction, and support update() / unmount() for mid-test mutations. Storybook stories are for design review; Playwright stories are for automated testing.
Why the gallery exists
The gallery is an HTML page that Playwright generates and serves. When mount('components/Expandable/Stateful') is called, Playwright navigates to the gallery with a query param identifying the story, the gallery renders the story, and Playwright returns a Locator to the story’s root element. This architecture means stories live in separate files (co-located with the component) but are served from a single page, avoiding the need to spin up a dev server per story.
💼 Interview Questions
Show Answer
Show Answer
Show Answer
Show Answer
🏋️ Practical Exercise
Author Your First Story
- Install Playwright 1.62:
npm install -D @playwright/test@1.62.1 - Pick a simple component (a Button or Expandable)
- Create a story file:
Button/Primary.story.tsxwithdefineStory({ component: Button, props: { variant: 'primary', children: 'Click me' } }) - Write a test:
const btn = await mount('Button/Primary'); await expect(btn).toHaveText('Click me'); - Run the test and verify it passes
- Try
await btn.update({ children: 'New text' })and assert the text changes
🚀 Mini Project
🏗️ Migrate a Component Suite
- Pick a component with 5+ CT tests using the old mount(Component, { props }) API
- Run
npx playwright ct-migrateon the test files - Review the generated story files and fix any codemod misses
- Run the migrated tests — they should pass without prop changes
- Add a new test that references an existing story by ID (DRY)
- Measure the line count reduction — typical migration cuts test code by 30–40%
📝 Quiz
Test your understanding. Click an option to check your answer.
❓ FAQ
Q: Can I use stories with non-React frameworks (Vue, Svelte)?
A: Yes. Stories work with any framework Playwright CT supports. The story file imports the framework-specific component and uses defineStory with framework-appropriate props. Note: @playwright/experimental-ct-svelte was removed in 1.59 — Svelte tests must use the main @playwright/test runner.
Q: Do stories replace Storybook?
A: No. Storybook is for visual design review — designers browse stories in a UI. Playwright stories are for automated testing — tests mount stories by ID and assert behavior. They’re complementary: Storybook for design, Playwright stories for tests. Some teams generate Playwright stories from Storybook stories.
Q: Can I view all stories in a browser like Storybook?
A: Yes. The gallery page is browseable. Run npx playwright ct-gallery and open the URL — you’ll see all stories rendered in a grid, similar to Storybook’s Story view. This is useful for visual review during development.
📦 Summary
🎯 Key Takeaways
- Stories wrap a component in a scenario — props, providers, mocks — defined once, referenced by ID.
- mount(storyId) returns a Locator scoped to the story’s root element.
- Use mount<StoryType>() for compile-time prop type-checking.
- update(props) re-renders mid-test; unmount() tears down explicitly.
- Migration from 1.61 is breaking — run the codemod, then author stories manually.
Comments
Comments
Post a Comment