| name | storybook-testing |
| description | Patterns for writing Storybook stories and interaction tests in k9-sak-web. USE FOR: creating stories for v2 components, writing play() interaction tests, setting up fake backend clients for stories, structuring Default vs interaction stories, and running the test runner. DO NOT USE FOR: Vitest unit tests (use for pure logic/utils only), MSW setup, or vi.spyOn HTTP mocking. |
Storybook Testing
Storybook is the primary testing tool for React components in v2. Interaction tests run in the Storybook test runner.
Story File Conventions
- File:
MyComponent.stories.tsx alongside MyComponent.tsx
- Title:
'gui/<path>/MyComponent.tsx' mirroring the file path
- Import from
@storybook/react-vite, not @storybook/react
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import { action } from 'storybook/actions';
import { fn } from 'storybook/test';
import MyComponent from './MyComponent.js';
const meta = {
title: 'gui/fakta/myfeature/MyComponent.tsx',
component: MyComponent,
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
Story Structure Rules
| Story type | Has play? | Purpose |
|---|
Default (or DefaultStory) | No | Interactive via args/controls in Storybook UI |
| Named interaction stories | Yes | Automated test scenarios run in test runner |
export const DefaultStory: Story = {
args: { title: 'Test', items: [] },
};
export const SendsBrevOnSubmit: Story = {
args: { ... },
play: async ({ canvas, step }) => {
await step('Fyll ut skjema', async () => {
await userEvent.click(canvas.getByLabelText('Mal'));
});
expect(await canvas.findByText('Sendt')).toBeInTheDocument();
},
};
Fake Backend Pattern
Never use MSW. Inject fake API clients via React Context.
Step 1: Fake class implements the API interface
import { action } from 'storybook/actions';
import type { MyFeatureApi } from '../../myfeature/api/MyFeatureApi.js';
import type { MyDataDto } from '@k9-sak-web/backend/k9sak/generated/types.js';
export class FakeMyFeatureApi implements MyFeatureApi {
public fakeDelayMillis = 800;
async getMyData(id: string): Promise<MyDataDto> {
action('getMyData')({ id });
return { id, name: 'Test data' };
}
async saveMyData(data: unknown): Promise<void> {
action('saveMyData')(data);
}
}
Step 2: Decorator wraps the context
import type { Decorator } from '@storybook/react';
import { FakeMyFeatureApi } from '../mocks/FakeMyFeatureApi.js';
import { MyFeatureApiContext } from '../../myfeature/api/MyFeatureApiContext.js';
export const withFakeMyFeatureApi = (): Decorator => Story => {
const api = new FakeMyFeatureApi();
return (
<MyFeatureApiContext value={api}>
<Story />
</MyFeatureApiContext>
);
};
Step 3: Use in stories
const api = new FakeMyFeatureApi();
const meta = {
title: 'gui/myfeature/MyComponent.tsx',
component: MyComponent,
decorators: [withFakeMyFeatureApi()],
beforeEach: () => {
api.reset();
},
} satisfies Meta<typeof MyComponent>;
Stateful fakes (for asserting calls)
export class FakeMyFeatureApi implements MyFeatureApi {
#lastSavedData: unknown;
get lastSavedData() { return this.#lastSavedData; }
reset() { this.#lastSavedData = undefined; }
async saveMyData(data: unknown): Promise<void> {
this.#lastSavedData = data;
action('saveMyData')(data);
}
}
play: async () => {
expect(api.lastSavedData).toEqual({ expected: 'value' });
},
Interaction Test Tips
Form Validation Stories
Collect validation/error stories in dedicated named stories:
export const VisValideringsfeil: Story = {
args: { },
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Send' }));
await expect(await canvas.findByText('Feltet er påkrevd')).toBeInTheDocument();
},
};
Running Tests
yarn storybook
yarn build-storybook-test
yarn test-storybook
yarn test-storybook -- --watch
Common Shared Mocks
Shared mock data and utilities live in packages/v2/gui/src/storybook/mocks/:
fakePdf.ts — returns a fake Blob for PDF preview tests
ignoreUnusedDeclared.ts — suppresses unused-variable lint in fakes
arbeidsgivere.json — reusable mock arbeidsgiver data
personopplysninger.ts — reusable mock personopplysninger
Stories with useSuspenseQuery
Components that use useSuspenseQuery need QueryClientProvider and Suspense in addition to the fake API context. Create a local withFakeApi decorator:
import type { Decorator } from '@storybook/react-vite';
import { Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MyFeatureApiContext } from './api/MyFeatureApiContext.js';
import type { MyDataDto } from '@k9-sak-web/backend/k9sak/generated/types.js';
import { createQueryClient } from "@k9-sak-web/gui/shared/query/queryClient.js";
const withFakeApi = (data: MyDataDto): Decorator => {
const queryClient = createQueryClient({ queries: { retry: false } });
return Story => (
<QueryClientProvider client={queryClient}>
<MyFeatureApiContext value={{ getMyData: async () => data }}>
<Suspense>
< />
);
};
meta = {
: [(mockData)],
: { : },
} < >;
Per-story data overrides use story-level decorators:
export const SpecificScenario: Story = {
decorators: [withFakeApi(alternativeData)],
};
Kodeverk decorator
Components that use K9KodeverkoppslagContext (the type-safe kodeverk system) need a decorator in stories. Use withK9Kodeverkoppslag() — it provides mock kodeverk for all backends:
import withK9Kodeverkoppslag from '@k9-sak-web/gui/storybook/decorators/withK9Kodeverkoppslag.js';
const meta = {
title: 'gui/fakta/myfeature/MyComponent',
component: MyComponent,
decorators: [withK9Kodeverkoppslag()],
} satisfies Meta<typeof MyComponent>;
For legacy components still using useKodeverkContext(), use withKodeverkContext() instead. Do not use withKodeverkContext() in new code.
Never inline kodeverk mock data in stories. The decorators cover all kodeverk lookups automatically.