Стори Storybook с авто-спаями (Angular)
С аддоном Storybook Vitest функция play у стори выполняется как тест Vitest в настоящем браузере. Стори создаёт компонент через настоящий Angular DI, поэтому сервисы подменяются так же, как в спеке на TestBed: через provideAutoSpy. Провайдер передайте в applicationConfig, спай сохраните в константу файла со стори, а настраивайте и проверяйте его в самих стори.
// 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');
},
};Проверено на Storybook 10.6 (@storybook/angular-vite, @storybook/addon-vitest), Angular 22 и Vitest 4.1 в browser mode с Chromium от Playwright.
Пока что Vitest 4
@storybook/addon-vitest 10.6 поддерживает только vitest ^3.0.0 || ^4.0.0. На Vitest 5 запускать через него стори пока нельзя. Сама библиотека работает на обеих версиях.
Компонент
Стори выше тестируют этот компонент. Он читает total() в инициализаторе поля, то есть пока создаётся, и вызывает checkout() по клику.
@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);
}
}Стори
Что делает каждая часть файла со стори:
provideAutoSpy(CartService)возвращает обычный провайдер{ provide, useValue }.useValue— типизированный спайSpy<CartService>. Провайдер уходит в Angular черезapplicationConfig, а спай хранится в константеcart, через неё его используют стори.- Один спай на файл, сброс перед каждой стори.
provideAutoSpyвыполняется один раз, при загрузке файла, и все стори работают с одним спаем. ПоэтомуbeforeEachуmetaвызываетresetAutoSpy(cart): он стирает вызовы и ответы предыдущей стори. После сброса метод возвращаетundefined, пока вы снова не зададите ему ответ. - Настраивайте в
beforeEach, а не вplay.playзапускается после рендера, а этот компонент вызываетtotal()ещё при создании. Storybook выполняетbeforeEachуmeta(он настраиваетtotal), затем у стори (он настраиваетcheckout) и только потом рендерит компонент. cart.checkout.resolveWith(order)отвечаетorderна любой вызов. СcalledWith('tok_abc')перед ним ответ получает только вызов с этим токеном; любой другой вернётundefined.findByRoleждёт, пока элемент появится, поэтому подходит и для данных, которые компонент загружает асинхронно, например вngOnInit.getBy…— только для того, что есть при первом рендере.expectберётся изvitest, а не изstorybook/test. Работают все привычные матчеры спаев:toHaveBeenCalledWith,toHaveBeenCalledTimes,toHaveBeenCalled. Почему — в следующем разделе.tags: ['!dev', '!autodocs']прячет эти стори из боковой панели и страницы документации Storybook, но оставляет их в прогоне тестов. Они импортируютvitest, который не загружается вstorybook dev, поэтому без этих тегов стори сломали бы интерфейс Storybook. Держите их в отдельном файле, например*.test.stories.ts, чтобы стори для просмотра оставались чистыми.
Почему expect берётся из vitest
У storybook/test есть свой expect, и примеры Storybook используют его. Со спаями этой библиотеки он падает:
TypeError: [Function] is not a spy or a call to a spy!expect из Storybook оборачивает спай в новую функцию, и матчер получает обёртку. expect из vitest передаёт спай как есть.
Два способа обойти:
- Импортировать
expectизvitest, как в примере. Вы теряете записи в панели Interactions, но эти стори и так скрыты из интерфейса. - Перевести файл на моки раннера: вызвать
setSpyEngine('runner')изvitest-auto-spy/setupв начале файла со стори, доprovideAutoSpy. Тогда каждый метод —vi.fn(), иexpectиз Storybook его принимает. Вы платите за панель частью скорости и памяти.
Чем это не является
- Не адаптер Storybook. Отдельного
vitest-auto-spy/storybookнет, а собственныйfn()изstorybook/testработает как прежде.vitest-auto-spy/angularиспользуется ровно так же, как в спеке. - Не мок модуля.
sb.mock()в.storybook/preview.tsподменяет модуль на всю сборку Storybook. Провайдер подменяет один сервис для стори одного файла.
Смотрите также
- Angular —
provideAutoSpy,injectSpyи те же провайдеры со стороныTestBed. - Хелперы управления —
calledWith,resolveWith,resetAutoSpy. - Паттерны спек — соглашения больших наборов тестов на Angular; большинство подходит и для стори.