Storybook stories with auto-spies (Angular)
With the Storybook Vitest addon, a story's play function runs as a Vitest test in a real browser. A story builds the component through real Angular DI, so you replace its services the same way as in a TestBed spec: with provideAutoSpy. Pass the provider to applicationConfig, keep the spy in a constant of the stories file, and configure and assert on it in the stories.
// cart.test.stories.ts
import { type Meta, type StoryObj, applicationConfig } from '@storybook/angular-vite';
import { expect } from 'vitest';
import { resetAutoSpy } from 'vitest-auto-spy';
import { provideAutoSpy } from 'vitest-auto-spy/angular';
import { CartComponent } from './cart.component';
import { CartService } from './cart.service';
const cartProvider = provideAutoSpy(CartService);
const cart = cartProvider.useValue; // Spy<CartService>
const meta = {
title: 'Cart/Interactions',
component: CartComponent,
tags: ['!dev', '!autodocs'],
decorators: [applicationConfig({ providers: [cartProvider] })],
beforeEach: () => {
resetAutoSpy(cart);
cart.total.mockReturnValue(42);
},
} satisfies Meta<CartComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ShowsTheTotal: Story = {
play: async ({ canvas }) => {
expect(canvas.getByText('Total: 42')).toBeTruthy();
},
};
export const ChecksOut: Story = {
beforeEach: () => {
cart.checkout.calledWith('tok_abc').resolveWith({ orderId: 'ord_42' });
},
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Check out' }));
expect((await canvas.findByRole('status')).textContent).toContain('Order ord_42');
expect(cart.checkout).toHaveBeenCalledWith('tok_abc');
},
};Checked with Storybook 10.6 (@storybook/angular-vite, @storybook/addon-vitest), Angular 22 and Vitest 4.1 in browser mode with Playwright's Chromium.
Vitest 4 for now
@storybook/addon-vitest 10.6 supports vitest ^3.0.0 || ^4.0.0 only. On Vitest 5 you cannot run stories through it yet. This library itself works on both.
The component
The stories above test this component. It reads total() in a field initializer, while it is being built, and calls checkout() on a click.
@Injectable({ providedIn: 'root' })
export class CartService {
total(): number {
/* reads the real cart */
}
checkout(token: string): Promise<Order> {
/* charges the real card */
}
}
@Component({
selector: 'app-cart',
template: `
<p>Total: {{ total }}</p>
<button type="button" (click)="checkout()">Check out</button>
@if (orderId()) {
<p role="status">Order {{ orderId() }}</p>
}
`,
})
export class CartComponent {
readonly #cart = inject(CartService);
readonly total = this.#cart.total();
readonly orderId = signal<string | null>(null);
async checkout(): Promise<void> {
const order = await this.#cart.checkout('tok_abc');
this.orderId.set(order.orderId);
}
}The stories
What each part of the stories file does:
provideAutoSpy(CartService)returns a plain{ provide, useValue }provider.useValueis the typed spy,Spy<CartService>. The provider goes to Angular throughapplicationConfig; the spy is kept in thecartconstant, where the stories use it.- One spy per file, reset before each story.
provideAutoSpyruns once, when the file loads, so every story shares the same spy. That is why the meta'sbeforeEachcallsresetAutoSpy(cart): it clears the previous story's calls and answers. After the reset, a method returnsundefineduntil you give it an answer again. - Configure in
beforeEach, not inplay.playruns after the component has rendered, and this component callstotal()while it is being built. Storybook runs the meta'sbeforeEach(it sets uptotal), then the story's (it sets upcheckout), and only then renders the component. cart.checkout.resolveWith(order)answers every call withorder. WithcalledWith('tok_abc')in front, only a call with that token gets the answer; any other call returnsundefined.findByRolewaits until the element appears, so it also covers data the component loads asynchronously, for example inngOnInit. UsegetBy…only for what is there on the first render.expectcomes fromvitest, not fromstorybook/test. All the usual spy matchers work:toHaveBeenCalledWith,toHaveBeenCalledTimes,toHaveBeenCalled. See the next section.tags: ['!dev', '!autodocs']hides these stories from the Storybook sidebar and docs page but keeps them in the test run. They importvitest, which does not load instorybook dev, so without these tags they would break the Storybook UI. Keep them in their own file, such as*.test.stories.ts, so the stories people browse stay clean.
Why expect comes from vitest
storybook/test has its own expect, and Storybook's examples use it. With this library's spies it fails:
TypeError: [Function] is not a spy or a call to a spy!Storybook's expect wraps the spy in a new function before the matcher sees it. The expect from vitest passes the spy through unchanged.
Two ways around it:
- Import
expectfromvitest, as in the example. You lose the Interactions panel entries, but these stories are hidden from the UI anyway. - Switch the file to runner mocks: call
setSpyEngine('runner')fromvitest-auto-spy/setupat the top of the stories file, beforeprovideAutoSpy. Every method is then avi.fn(), and Storybook'sexpectaccepts it. You trade some speed and memory for the panel.
What this is not
- Not a Storybook adapter. There is no
vitest-auto-spy/storybookto install, and Storybook's ownfn()fromstorybook/testworks as before. You usevitest-auto-spy/angularexactly as in a spec. - Not a module mock.
sb.mock()in.storybook/preview.tsreplaces a module for the whole Storybook build. A provider replaces one service for one file's stories.
Related
- Angular —
provideAutoSpy,injectSpyand theTestBedside of the same providers. - Control helpers —
calledWith,resolveWith,resetAutoSpy. - Spec patterns — conventions for large Angular test suites; most apply to stories too.