📖 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

1. Author a story file: components/Expandable/Stateful.story.tsx
⬇️
2. Gallery page serves stories on demand by ID
⬇️
3. Test calls mount('components/Expandable/Stateful')
⬇️
4. Playwright navigates to gallery, mounts story, returns Locator
⬇️
5. Test interacts and asserts against the scoped Locator

⚙️ 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.

typescript
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.

typescript
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).

typescript
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)

typescript
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+)

typescript
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.

⚠️ Gotcha

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

  1. One story per scenario — if two tests need the same props, they should reference the same story.
  2. Keep stories declarative — props, providers, mocks only. No assertions or interactions.
  3. Use mount<StoryType>() for compile-time prop type-checking.
  4. Use update() for mid-test prop changes — don’t re-mount.
  5. 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

Beginner
What is a story in Playwright 1.62 component testing?
Show Answer
A story wraps a component in one specific scenario — hard-coded props, mock data, providers. It lives in a story file co-located with the component. Tests reference stories by ID via mount(storyId), and the story’s props are type-checked at compile time.
Intermediate
How does the new mount() differ from the old mount(Component, { props })?
Show Answer
The old mount took a component constructor and inline props. The new mount takes a story ID string — props come from the story file. This makes scenarios reusable across tests, type-checks props at compile time, and centralizes configuration in story files.
Advanced
What is the gallery in the stories-and-galleries model?
Show Answer
The gallery is an HTML page that Playwright generates and serves. When mount(storyId) is called, Playwright navigates to the gallery with a query param identifying the story, the gallery renders it, and Playwright returns a Locator to the story's root. This avoids spinning up a dev server per story.
Scenario
You have 200 CT tests with inline mount calls. How do you migrate to 1.62?
Show Answer
Run npx playwright ct-migrate first — it handles ~80% of the mechanical changes (converting mount(Component, { props }) to mount(storyId) and generating story files). Then manually author stories for any inline mount calls the codemod couldn't handle. Budget a day for a typical 200-test suite, and run old and new suites in parallel for a sprint.

🏋️ Practical Exercise

🎯 Hands-On Practice hard

Author Your First Story

  1. Install Playwright 1.62: npm install -D @playwright/test@1.62.1
  2. Pick a simple component (a Button or Expandable)
  3. Create a story file: Button/Primary.story.tsx with defineStory({ component: Button, props: { variant: 'primary', children: 'Click me' } })
  4. Write a test: const btn = await mount('Button/Primary'); await expect(btn).toHaveText('Click me');
  5. Run the test and verify it passes
  6. Try await btn.update({ children: 'New text' }) and assert the text changes

🚀 Mini Project

🏗️ Migrate a Component Suite

  1. Pick a component with 5+ CT tests using the old mount(Component, { props }) API
  2. Run npx playwright ct-migrate on the test files
  3. Review the generated story files and fix any codemod misses
  4. Run the migrated tests — they should pass without prop changes
  5. Add a new test that references an existing story by ID (DRY)
  6. Measure the line count reduction — typical migration cuts test code by 30–40%

📝 Quiz

Test your understanding. Click an option to check your answer.

1. What does fixtures.mount(storyId) return?
A) A Page object
B) A Locator scoped to the story's root element
C) A string
D) A void promise
Answer: B — mount(storyId) returns a Locator scoped to the story's root element. You interact with the component via this Locator.
2. How do you type-check a story's props at compile time?
A) Pass props as a JSON string
B) Use mount(storyId) with a template argument
C) You can't — props are untyped
D) Use a separate TypeScript config
Answer: B — mount(storyId) passes the story type as a template argument, enabling compile-time prop type-checking.
3. How do you re-render a story with new props mid-test?
A) Call mount() again with new props
B) Use component.update({ newProps })
C) You can't — stories are immutable
D) Use page.reload()
Answer: B — component.update(props) re-renders the story with new props without re-mounting. Re-mounting creates a fresh instance and loses state.
4. What is a gallery in the stories-and-galleries model?
A) A visual regression tool
B) An HTML page that serves stories on demand by ID
C) A npm package for component screenshots
D) A CI dashboard
Answer: B — The gallery is an HTML page Playwright generates. When mount(storyId) is called, Playwright navigates to the gallery, which renders the story by ID.
5. Is the migration from 1.61 to 1.62 CT breaking?
A) No, it's backwards compatible
B) Yes — the mount() signature changed
C) Only for Svelte
D) Only if you use TypeScript
Answer: B — The mount() signature changed from mount(Component, { props }) to mount(storyId). Existing CT tests need migration via the codemod plus manual story authoring.
6. Which Playwright version introduced stories and galleries?
A) 1.60
B) 1.61
C) 1.62
D) 1.63
Answer: C — Stories and galleries shipped in Playwright 1.62 (July 24, 2026).
7. Where should business logic (assertions, interactions) live?
A) In the story file
B) In the test file, not the story
C) In the gallery
D) In playwright.config.ts
Answer: B — Stories are declarative — props, providers, mocks only. Assertions and interactions belong in the test file. Stories should be reusable configuration.
8. What does the codemod handle during migration?
A) 100% of changes — fully automatic
B) ~80% — mechanical changes, you author stories manually
C) Nothing — it's a no-op
D) Only Svelte tests
Answer: B — npx playwright ct-migrate handles ~80% of mechanical changes (converting mount calls). You still need to author story files for scenarios the codemod can't infer.
9. Should you re-mount or update() for mid-test prop changes?
A) Re-mount — it's faster
B) update() — re-mounting loses state
C) Either works the same
D) Neither — use page.reload()
Answer: B — Use component.update(props) for mid-test changes. Re-mounting with mount() creates a fresh instance and loses any component state.
10. What is the typical test code reduction after migrating to stories?
A) No reduction
B) ~10%
C) ~30-40%
D) ~80%
Answer: C — Typical migration cuts test code by 30-40% because props are centralized in story files and referenced by ID, eliminating duplication.

❓ 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.