Every network request in the app is defined with a co-located mock. A single env var routes all queryFn calls to those mocks. Storybook runs with that env var always set — every story renders against deterministic data with zero setup. The same stories are executed as integration tests by Vitest’s Storybook project. No MSW, no per-story fixtures, no separate test data files.
The single source of truth
Every query definition ships its own mock alongside the real fetcher:
// features/api-hooks/src/rules/ruleInstancesQueryOptions.ts
export function ruleInstancesQueryOptions(filter: RuleFilter) {
return graphqlQueryOptions({
queryKey: ["rules", "instances", filter],
query: LIST_RULE_INSTANCES,
variables: { filter },
mock: {
listRuleInstances: {
items: [
{ id: "rule-1", name: "MFA not enabled", severity: "HIGH", status: "ENABLED",
latestAlertCount: 3, lastEvaluatedOn: "2026-06-01T10:00:00Z" },
],
pageInfo: { endCursor: null, hasNextPage: false },
},
},
});
}
Mock data is always deterministic — no Date.now(), no Math.random(). The mock can be a static value or a function (variables?) => TData for cases where different inputs should return different shapes.
The factory
const OFFLINE_MODE = import.meta.env.VITE_OFFLINE_MODE === "true";
function graphqlQueryOptions<TData, TVars>({ queryKey, query, variables, mock }: {
queryKey: readonly unknown[];
query: string;
variables?: TVars;
mock: TData | ((vars?: TVars) => TData);
}) {
return {
queryKey,
queryFn: OFFLINE_MODE
? () => Promise.resolve(typeof mock === "function" ? mock(variables) : mock)
: () => apiFetcher<TData>(GQL_URL, { query, variables }),
retry: OFFLINE_MODE ? false : undefined, // no retry noise in Storybook interaction tests
};
}
One branch, one env var, no per-story configuration.
Storybook setup
// .storybook/main.ts
export default {
viteFinalConfig: async (config) => {
config.define ??= {};
config.define["import.meta.env.VITE_OFFLINE_MODE"] = JSON.stringify("true");
return config;
},
};
Every story in the entire app now renders offline by default. No parameters.msw, no beforeEach server setup, no handlers array.
Global providers in preview.tsx
A single preview.tsx wraps every story with the same providers the real app uses:
// .storybook/preview.tsx
const preview: Preview = {
decorators: [
(Story) => (
<QueryClientProvider client={storybookQueryClient}>
<TooltipProvider>
<SessionProvider value={mockSession}>
<ThemeProvider>
<Story />
</ThemeProvider>
</SessionProvider>
</TooltipProvider>
</QueryClientProvider>
),
],
};
The storybookQueryClient has staleTime: Infinity and retry: false. Queries resolve from the mock immediately and never re-fetch.
Authorization in Storybook
Session flags, entitlements, and ABAC permissions are seeded globally via the SessionProvider mock. Default: full admin with all flags on. Individual stories override specific permissions:
// story that tests the read-only view
export const ReadOnly: Story = {
decorators: [
withSession({ permissions: { canCreate: false, canUpdate: false } }),
],
};
// story that tests behind a feature flag
export const BehindFlag: Story = {
decorators: [
withFeatureFlags({ "release-new-rules-ui": false }),
],
};
withSession and withFeatureFlags are thin decorators that merge overrides into the mock session context. Auth-gated components test their disabled states without any real auth system.
Vitest as the test runner
Vitest runs stories as integration tests via its Storybook project:
// vitest.workspace.ts
export default defineWorkspace([
{ extends: "vitest.config.ts", test: { name: "unit", include: ["**/*.test.ts"] } },
{
extends: "vitest.config.ts",
test: {
name: "storybook",
browser: { enabled: true, name: "chromium", provider: "playwright" },
include: ["**/*.stories.tsx"],
},
},
]);
Stories with play functions become interaction tests. The same mock-backed stories used for visual development run as automated assertions in CI.
export const SubmitsForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("Name"), "My Rule");
await userEvent.click(canvas.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(canvas.getByText("Rule saved")).toBeInTheDocument();
});
},
};
CI sharding
The storybook project is sharded across 4 parallel runners in CI:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: VITE_OFFLINE_MODE=true pnpm vitest run --project storybook --shard=$/4
VITE_OFFLINE_MODE=true is redundant (Storybook’s Vite config already sets it) but is explicit here for clarity in CI logs.
What you get for free
- Every component is documented with realistic data (the mock is the documentation).
- Every story runs as a test without writing separate test files.
- CI never makes real network requests.
- Adding a new query automatically makes its consuming stories testable offline — no MSW handler to register.
- Flaky tests from network calls or timing are structurally impossible.