Rules for writing Storybook stories and interaction tests for React components in CSF Next: how to query, awaiting interactions, when to use .test() or play, portals, mocks, naming and file setup. Use when writing, fixing or reviewing a .stories.tsx file. Background knowledge that agents preload; not a command.
Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of Storybook Rules?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/janszewczyk-storybook-rules)
---
name: storybook-rules
description: "Rules for writing Storybook stories and interaction tests for React components in CSF Next: how to query, awaiting interactions, when to use .test() or play, portals, mocks, naming and file setup. Use when writing, fixing or reviewing a .stories.tsx file. Background knowledge that agents preload; not a command."
user-invocable: false
---
<!-- generated by knowledge.mjs from 26 note(s) (sha256 459f94d18a1a); edit the notes in knowledge/, then run build -->
# Storybook rules
Apply these rules to every story file you write or review. The first section is always in force; open a rule from the table when the task touches it.
## Always apply (critical, high)
### Make every test able to fail when the behaviour it names breaks
_critical · any_
**Rule.** End every test with an assertion that would fail if the behaviour in its name broke. Before you
trust a new test, check that it can fail: break the condition it checks, or assert the opposite,
and see it go red.
**Why.** A test that only renders and clicks, or asserts something that is always true, stays green while
the feature is broken, so the suite reports coverage that does not exist.
### Fix the cause of a flaky test instead of rerunning it until it passes
_critical · any_
**Rule.** Treat a test that fails without a code change as a defect: find its nondeterminism (clock, random data, scheduling, network, test order, shared state) and remove it.
A rerun (`--reruns`, `@flaky`, `retryTimes`, `retry`) is never the fix: a suite that reruns reports each test that passed only on a rerun as flaky, to be fixed.
**Why.** A rerun turns the suite green while the race or leak behind the failure is still in the code, and
once red can mean "just flaky", people stop reading failures and miss the real ones.
### Wait for a condition the user would see, never for a fixed time
_critical · any_
**Rule.** When the result of an action arrives later, wait for the condition itself: a query that retries
until the element appears, or a retrying assertion on the value or call you expect. Give a long
operation a longer timeout on that wait instead of a sleep before it.
**Why.** A fixed sleep is too short on a slow CI machine and too long everywhere else, so the suite is both
flaky and slow, and its failures name the assertion after the sleep instead of what never happened.
### Await every userEvent call and every expect in a story test
_critical · typescript, storybook@>=10_
**Rule.** Put `await` before every `userEvent.*` call and every `expect(...)` in a `.test()` or `play`
function, including assertions inside `step()` and `waitFor()`.
**Why.** Without `await` the test function returns before the interaction or assertion settles, so the test
passes while the behaviour it names was never checked, and the failure surfaces later in an unrelated
test.
**Incorrect:**
```ts
Story.test("Clicking submit calls onSubmit", async ({ canvas, userEvent, args }) => {
userEvent.click(canvas.getByRole("button", { name: /submit/i }));
expect(args.onSubmit).toHaveBeenCalledOnce();
});
```
**Correct:**
```ts
Story.test("Clicking submit calls onSubmit", async ({ canvas, userEvent, args }) => {
await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
await expect(args.onSubmit).toHaveBeenCalledOnce();
});
```
### Take userEvent from the test function's parameters, never from an import
_critical · typescript, storybook@>=10_
**Rule.** Destructure `userEvent`, `canvas`, `canvasElement`, `args` and `step` from the parameters of the
`.test()` or `play` function. Never import `userEvent`, `within` or `canvas`. In a story file,
import only `expect`, `fn`, `waitFor`, `screen` and `mocked` from `storybook/test`; `sb` belongs in
the Storybook preview file only.
**Why.** An imported `userEvent` is not wired into Storybook's instrumentation, so interactions run outside
its timing and do not show up in the interactions panel. The failures it causes are intermittent
and hard to trace back to the import.
**Incorrect:**
```ts
import { expect, fn, userEvent } from "storybook/test";
Story.test("Submits the form", async ({ canvas }) => {
await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
});
```
**Correct:**
```ts
import { expect, fn } from "storybook/test";
Story.test("Submits the form", async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
});
```
### Keep the unit under test real, and change the test rather than the code
_high · any_
**Rule.** Mock only what the unit under test depends on, never the unit itself. When a new test fails, fix
the test; if the failure shows a real defect in the code, report it instead of editing the code or
weakening the assertion to make the test pass.
**Why.** A mocked or quietly patched unit makes the test check itself: it passes while the shipped code is
still broken, and the bug it found disappears from view.
### Find elements the way a user or a screen reader does
_high · any_
**Rule.** Locate elements by their role and accessible name first, then by their label, then by visible
text. Reach for a test id only when the element has no accessible identity at all, and fix the
component when that is because its semantics are wrong.
**Why.** A test that finds a button by its role and name keeps passing through any refactor that keeps the
button usable, and fails when a user could no longer find it. A test that finds it by class name or
test id breaks on harmless markup changes and stays green when the accessible name disappears.
**Exceptions.** A test id is fine for an element with correct semantics that still has nothing stable to query by, such
as dynamic text and no role. It beats a CSS class, because it makes the link between test and code explicit.
### Make every test pass alone and in any order
_high · any_
**Rule.** Write each test so it passes when run alone and in any order. Configure and reset mocks before each
test, and share repeated setup through a small helper function, never through a mutable variable
that tests write to.
**Why.** A test that leans on an earlier one passes in the full run and fails when filtered or reordered,
which shows up as a flaky suite whose failures point at the wrong test.
### Choose the mock by what is faked
_high · typescript, storybook@>=10_
**Rule.** Fake a callback prop with `fn()` in `meta.args`, a request the component sends with MSW handlers in
`parameters.msw`, a module it imports with `sb.mock()` in the Storybook preview file plus `mocked()`
in `beforeEach`, and React context or providers with decorators. Never pass a provider value as a prop
or register a module mock inside a story file.
**Why.** A mock of the wrong kind fakes something the component never touches: a `fn()` prop does not stop the
real `fetch`, and `sb.mock()` in a story file runs after the module is loaded, so the story hits the
real dependency.
**Correct:**
```ts
export const Loaded = meta.story({
parameters: { msw: { handlers: [http.get("/api/invoices", () => HttpResponse.json(invoices))] } },
});
```
### Query portal content through screen, not canvas
_high · typescript, storybook@>=10_
**Rule.** Trigger the interaction through `canvas`, then find the portal content through `screen`, which
queries the whole document. Use `canvas` for everything rendered inside the story root.
**Why.** Modals, popovers, tooltips and select option lists are portalled to `document.body`. A `canvas`
query never finds them, so the test fails with "unable to find role dialog" even though the dialog
is plainly on screen.
**Correct:**
```ts
await userEvent.click(canvas.getByRole("button", { name: /open/i }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(screen.getByRole("button", { name: /close/i }));
```
### Attach independent checks to one story with .test(), not separate play stories
_high · typescript, storybook@>=10_
**Rule.** Write every independent assertion as its own `.test()` on the story that has the right args. Use
`play` only for a demo without assertions, or for one flow whose steps genuinely depend on each
other, grouped with `step()`.
**Why.** Separate stories per test multiply `meta.story()` boilerplate and fill the sidebar with copies of
the same state. Assertions packed into one `play` stop at the first failure, so one broken
behaviour hides the result of every check after it.
**Correct:**
```ts
export const FilledForm = meta.story({ args: { defaultValues: filled } });
FilledForm.test("Clicking submit calls onSubmit", async ({ canvas, userEvent, args }) => {
await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
await expect(args.onSubmit).toHaveBeenCalledOnce();
});
FilledForm.test("Disables submit while saving", async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
await expect(canvas.getByRole("button", { name: /submit/i })).toBeDisabled();
});
```
### Write stories with CSF Next factories, not CSF 3 objects
_high · storybook@>=10_
**Rule.** Build every story through the chain `definePreview` → `preview.meta()` → `meta.story()`.
`definePreview` lives only in the Storybook preview file. A story file has no default export and
no hand-written `Meta` or `StoryObj` types. How the preview is imported and what the story file is
called depends on the framework; each framework has its own note implementing this one.
**Why.** CSF 3 objects (`satisfies Meta<typeof C>`, `export default meta`) cannot carry `.test()`, and
their types are annotated by hand, so they drift from the component's props. Mixing both formats
in one project makes every new story a guess.
### Put a React story next to its component and import the typed preview
_high · typescript, react, storybook@>=10_
**Rule.** Create `<component>.stories.tsx` in the component's folder. Import the preview with the alias the
project's existing stories already use (`~/.storybook/preview`, `#.storybook/preview`, …), never
a deep relative path, and build the meta with `preview.meta({ ... })`.
Export stories as `meta.story({ ... })` and let TypeScript infer every type.
**Why.** The typed preview is what makes `meta.story()` know the component's props. A story file that
imports `Meta` from the framework package falls back to CSF 3 and loses `.test()`.
**Correct:**
```tsx
import preview from "~/.storybook/preview";
import { SubmitButton } from "./submit-button";
const meta = preview.meta({ title: "Components/Submit Button", component: SubmitButton });
export const SubmitButtonStory = meta.story({ name: "Submit Button" });
```
### Pick getBy, queryBy or findBy by what the assertion expects
_high · typescript, testing-library_
**Rule.** Use `getBy*` for an element that must already be on screen, `queryBy*` when the assertion is that
it is absent, and `findBy*` (or `waitFor`) for anything that appears after an interaction, a
request or an animation.
**Why.** `getBy*` throws before a `not.toBeInTheDocument()` assertion can run, so a negative check written
with it fails for the wrong reason. A `getBy*` on content that renders a tick later fails
intermittently, which reads as a flaky suite rather than a wrong query.
**Correct:**
```ts
await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: /save/i }));
await expect(await canvas.findByText(/saved/i)).toBeVisible();
```
## Read when the task touches it
| Impact | Rule | Applies to |
| --- | --- | --- |
| medium | [Assert what the user sees or can do, never internal state or markup](references/rules.md#assert-what-the-user-sees-or-can-do-never-internal-state-or-markup) — Check visibility, text, values, focus, ARIA state and callback calls; never state, class names or markup snapshots. | any |
| medium | [Keep test setup inline until repetition hides what differs between tests](references/rules.md#keep-test-setup-inline-until-repetition-hides-what-differs-between-tests) — A helper takes overrides and returns what the test needs, with no branch per case; a test shows what matters to it. | any |
| medium | [Derive the expected value from the test's own input, not a repeated literal](references/rules.md#derive-the-expected-value-from-the-tests-own-input-not-a-repeated-literal) — Assert against the data the test passed in (items.length, the prefilled value), never a hard-coded copy of it. | any |
| medium | [Keep snapshots small and inline, for output whose exact text matters](references/rules.md#keep-snapshots-small-and-inline-for-output-whose-exact-text-matters) — Snapshot error messages, logs or transformed code inline; replace a large snapshot with explicit assertions. | any |
| medium | [Query by the text users read, not by translation keys](references/rules.md#query-by-the-text-users-read-not-by-translation-keys) — Look up the default locale's real text, so a changed label is a change the test reports. | any |
| medium | [Use semantic HTML instead of adding ARIA so that a query works](references/rules.md#use-semantic-html-instead-of-adding-aria-so-that-a-query-works) — The right element and input type give the implicit role; add role or aria-* only for what HTML cannot express. | any |
| medium | [Group assertions by category, not one test per element](references/rules.md#group-assertions-by-category-not-one-test-per-element) — One content test per state, one accessibility test, one test per distinct interaction, rule or callback. | typescript, storybook@>=10 |
| medium | [Make a separate story only for a different state, not for a different test](references/rules.md#make-a-separate-story-only-for-a-different-state-not-for-a-different-test) — Different args or mocks make a new story; the same args with several checks make one story with several tests. | typescript, storybook@>=10 |
| medium | [Give a purely presentational component stories, not invented tests](references/rules.md#give-a-purely-presentational-component-stories-not-invented-tests) — A card or badge with no behaviour is documented by one story per visual variant; add a test only for real behaviour. | typescript, storybook@>=10 |
| low | [Derive a story variant with Story.extend instead of copying its config](references/rules.md#derive-a-story-variant-with-storyextend-instead-of-copying-its-config) — A variant of an existing story extends it and sets only what differs. | typescript, storybook@>=10 |
| low | [Label the phases of a long story test with step(), and only of a long one](references/rules.md#label-the-phases-of-a-long-story-test-with-step-and-only-of-a-long-one) — Wrap each phase in step() when a test has three or more (fill, submit, verify); leave short tests flat. | typescript, storybook@>=10 |
| low | [Name stories after component states and tests after behaviours](references/rules.md#name-stories-after-component-states-and-tests-after-behaviours) — Stories are states (EmptyForm, Loading), tests are sentences; never Default, Basic or Test 1. | typescript, storybook@>=10 |