If you open this repository and look at the test setup, the first thing you'll notice is that there's almost no "classic" test code lying around. There's a single `*.test.ts` file, a handful of config files, and then — a wall of Storybook stories. That's not an accident. It's the result of a deliberate choice about what we want to test, where we want the tests to live, and who is going to read them.
This article is the story of that choice: the stack, the architecture, the patterns, and the reasoning behind each one.
The short version
The project uses Vitest in browser mode — tests actually run in a real Chromium browser via Playwright — together with the Storybook Test addon. The core idea is that stories are tests: each story describes a state of a component, and play functions verify the interaction. Network requests are mocked with MSW, accessibility is checked with the a11y addon, and coverage is collected with Istanbul and published to Codecov.
But the why is more interesting than the what.
Why stories, and not a pile of test files?
Let's be honest: nobody reads test files. They're written, they run in CI, and they're forgotten until they break. But a Storybook catalog? That's something people actually open. Designers look at it. New developers browse it to understand what components exist. It's documentation that happens to be alive.
So the project made a bet: make the tests live where the documentation already lives. A story is a component state — "here's the form with a filled email", "here's the form with a server error". The play function turns that state into a test by simulating what a user would do and asserting what should happen. One artifact, three jobs: a living component catalog, autodocs, and executable tests.
This is the "single source of truth" idea taken seriously. Instead of maintaining a component, a story, and a test that all describe the same thing in three different places, you maintain one story that is the test. When the component changes, the story changes with it — and the test changes with it too, because they're the same file.
// SubscriptionForm.stories.tsx
export const FilledEmail: Story = {
args: { ...Basic.args },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emailInput = canvas.getByLabelText(/email/i);
await userEvent.type(emailInput, "test@example.com");
await expect(emailInput).toHaveValue("test@example.com");
},
};Why a real browser instead of jsdom?
This is the choice that surprises people the most. Most React projects test in jsdom — a simulated DOM that runs in Node. It's fast, it's simple, and it's not quite real. Focus behavior, portals, layout, real event semantics — jsdom fakes or approximates all of these.
I decided that if we're going to test UI, we should test it in the thing that actually renders it: a browser. So both Vitest projects run in headless Chromium through Playwright. The payoff is that tests verify behavior the way a user actually experiences it — real focus, real portals, real events. The cost is that tests are slower and need a browser installed, which is a trade-off the project accepted deliberately.
The config reflects this in two projects:
storybookruns the stories defined in the Storybook config. The `storybookTest` plugin boots Storybook and runs each story in the browser.unitclassic unit tests (src/**/*.test.{ts,tsx}) wired throughstorybookNextJsPluginso they understand Next.js (App Router,next/navigation, and friends).
// vitest.config.ts (fragment)
projects: [
defineProject({
plugins: [storybookTest({ configDir: ".storybook", storybookUrl: "http://localhost:6006" })],
test: { name: "storybook", browser: { enabled: true, provider: playwright(...) } },
}),
defineProject({
plugins: [storybookNextJsPlugin()],
test: { name: "unit", include: ["src/**/*.test.{ts,tsx}"] },
}),
],The patterns, and the thinking behind them
1. Stories as tests
The primary way of testing is stories with play functions. A story is a component state; play describes the interaction and the assertions. This gives you three things from one artifact: a living component catalog, documentation (autodocs), and executable tests.
The reason this works so well for a blog is that most of the UI is stateful but small: a form that validates, a button that disables while loading, a drawer that opens. Each of those is naturally a story. You're not inventing a test harness — you're just describing the states the component already has.
2. Mocking the API with MSW
Forms and comments talk to a backend. But a test should never depend on a real backend — that's slow, flaky, and impossible in CI. So each scenario gets its own set of MSW handlers. This lets us cover success, error, and loading states without a server in sight.
export const SuccessfulSubmission: Story = {
parameters: {
msw: {
handlers: [
http.post("/api/form", () =>
HttpResponse.json({ message: "Successfully subscribed!" }, { status: 200 }),
),
],
},
},
play: async ({ canvasElement }) => {
// fill the form, hit submit, wait for the response
await expect(canvas.getByText(/subscription successful/i)).toBeInTheDocument();
},
};A typical form gets a small family of stories, each covering one slice of behavior. The nice thing about this arrangement is that the states are the documentation. If you want to know what a form looks like while submitting, you open the LoadingState story. The test and the demo are the same thing.
3. Accessibility as a first-class citizen
Accessibility is easy to treat as an afterthought — a checklist you run at the end. This project instead bakes it into the stories. Dedicated stories check keyboard navigation and ARIA attributes: userEvent.tab() plus focus assertions, and toHaveAttribute("type", ...). On top of that, @storybook/addon-a11y runs automated accessibility checks.
export const AccessibilityTest: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.tab();
await expect(emailInput).toHaveFocus();
await expect(submitButton).toHaveAttribute("type", "submit");
},
};Why a whole story just for this? Because keyboard navigation is a behavior, and behaviors deserve tests. If someone breaks tab order, a story that walks through the form with tab() will catch it — and it'll catch it in the same place where the component is documented.
4. Portals and contexts
Some components render into a portal — a drawer, a dialog, a toast. In that case the content isn't inside canvasElement; it's in document.body. So the play function queries the body instead. And when a component needs a context (like AuthProvider), it's wired in through a decorator.
const meta: Meta<typeof Comments> = {
component: Comments,
decorators: (Story) => (
<AuthProvider>
<Story />
</AuthProvider>
),
};
// inside the play function
const body = within(document.body);
await expect(body.getByRole("heading", { name: "Comments" })).toBeInTheDocument();This is a small but important detail: it's the kind of thing that *only works* because we're in a real browser. In jsdom, portals and focus behave differently, and tests like this quietly become unreliable.
5. Unit tests via composeStories
For the rare cases where a plain unit test is the right tool, the project reuses stories through composeStories — testing already-described states instead of duplicating them.
// SubscriptionForm.test.ts
import { composeStories } from "@storybook/nextjs-vite";
import * as stories from "./SubscriptionForm.stories";
const { Basic } = composeStories(stories);
test("Basic story renders correctly", async () => {
await Basic.run();
expect(Basic).toBeDefined();
});The point here is consistency: even when we step outside the Storybook runner, we still lean on the stories as the source of truth. There's one way to describe a component state, and everything else builds on it.
Coverage: measuring without gaming the numbers
Coverage is collected with Istanbul, and the config deliberately excludes the stories themselves. That's a subtle but meaningful choice. If stories counted toward coverage, they'd inflate the numbers — a story that renders a component would look like coverage even if it never asserted anything meaningful. By excluding them, the metric reflects the actual logic in src, not the test scaffolding.
## CI: making the tests matter
Tests are only useful if they run automatically. Two GitHub Actions workflows handle that:
test.yml— runs on PRs and pushes tomain/develop: installs dependencies, runspnpm test, and uploads coverage to Codecov withfail_ci_if_error: true. If coverage upload fails, the build fails — no silent gaps.storybook.yml— builds and deploys Storybook to GitHub Pages, so the catalog (and the tests' home) is always live.
A real-world wrinkle: the browser problem
Here's a story that shows why this setup is the way it is. Playwright ships a bundled Chromium that's an Ubuntu build. On Fedora/Bazzite — immutable distros — that build can't run, because it's linked against Ubuntu-specific libraries. The project works around this by auto-detecting a system-installed Chromium (/usr/bin/chromium-browser) and using it, while CI on Ubuntu just uses the bundled browser.
This is a small detail, but it's the kind of thing that makes a browser-based testing strategy viable on a developer's actual machine — not just in CI. If the tests only ran on Ubuntu, they'd be a CI-only concern, and the "stories are tests" workflow would fall apart locally.
The setup isn't the most conventional, and it isn't the fastest. But it's built around a simple conviction: tests should live where people actually look, run in the environment that actually renders the UI, and describe behavior in a way that reads like a story — because, in the end, that's what they are.