空雲 Blog

Eye catchStorybook + Vite + React のインタラクションテストで外部モジュールモックする

publication: 2024/05/08
update:2024/05/21

@storybook/nextjs使用時のインタラクションテストとモジュールモック

Storybook でインタラクションテストを使用すると、コンポーネントの動作を GUI で確認しつつテストを書くことが出来ます。Jest や Vitest の CUI 上で表示確認無しでテストを書くのに比べると、圧倒的に楽にテストを書くことが可能です。ただし問題があって、Jest や Vitest から専用コマンドでテストを呼び出すときには、依存パッケージなどの関数が簡単にモック化できますが、Storybook のインタラクションテストではデータモック止まりになってしまいます。これを解決するため、以前に@storybook/nextjs用に Webpack と Babel の挙動をカスタマイズしてstorybook-addon-module-mockを作りました。これを使えば import した外部モジュール内の関数をモック化することが可能です。公式ドキュメントにも紹介されています。ただし、Webpack の使用が必須となるため、Vite を使用している場合は使用できません。

@storybook/react-vite使用時のインタラクションテストとモジュールモック

@storybook/react-viteはその名の通り Vite を経由してモジュールバンドルが行われます。Webpack を前提とするstorybook-addon-module-mockは使えません。ということで今回、Vite 使用時にモジュールをモックするためstorybook-addon-vite-mockを開発しました。Vite の挙動をカスタマイズし、外部モジュールの関数をモック化することが可能となります。

モジュールモックの原理

モジュールモックを作るためには export する直前の function に割り込んで、オリジナル関数とカスタム関数を切り替える必要があります。それを実現するため、Vite の Plugin を作成し、そこからtransform中でコードを変換しなければなりません。transformはモジュールの内容を変換するための関数で、その中で JavaScript の AST を解析し、exportする直前の関数を探し出し、切り替える機能を割り込ませます。原理は簡単なのですが、変換対象のコードは ESM や CJS、export の書き方など様々なパターンがあるため、実装はそれなりに大変です。今回作ったものは Storybook から使用することを前提に作っているのですが、Vite の Plugin として独立させても良いかもしれません。ただ、用途はなさそうな気がします。

Storybook の Addon としての実装

Vite 用 Plugin で割り込み処理を書いたら、次は Storybook の Addon 作成です。インタラクションテストから簡単に割り込めるように、モックの切り替え機能を実装します。このあたりの処理はかなりの部分をstorybook-addon-module-mockから持ってきたので、実装の手間をかなり省くことが出来ました。

Addon の組み込み方

@storybook/react-viteを使用している場合、storybook-addon-vite-mockをインストールします。その後、.storybook/main.jsに以下の設定を追加します。

  • .storybook/main.ts の例

options は指定しなくても動作します。debugPath は指定すると、変換状態を確認するためのファイルが出力されます。

1/** @type { import('@storybook/react-vite').StorybookConfig } */
2const config = {
3 stories: [
4 "../stories/**/*.mdx",
5 "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
6 ],
7 addons: [
8 "@storybook/addon-onboarding",
9 "@storybook/addon-links",
10 "@storybook/addon-essentials",
11 "@chromatic-com/storybook",
12 "@storybook/addon-interactions",
13 "@storybook/addon-coverage",
14 {
15 name: "storybook-addon-vite-mock",
16 options: {
17 exclude: ({ id }) => id.includes(".stories."),
18 // debugPath: "tmp",
19 },
20 },
21 ],
22 build: {
23 test: {
24 disabledAddons: [],
25 },
26 },
27 framework: {
28 name: "@storybook/react-vite",
29 },
30 docs: {
31 autodocs: "tag",
32 },
33};
34export default config;

サンプルソース

https://github.com/SoraKumo001/storybook-addon-vite-mock-test

関数の呼び出しパラメータのフック

login 関数をモック化して、引数を確認するのに使用しています。

  • login.ts

1export default (_user: string, _name: string) => {
2 //
3};

  • FormMock.tsx

1import React, { FC } from "react";
2import login from "./login";
3
4interface Props {}
5
6/**
7 * FormMock
8 *
9 * @param {Props} { }
10 */
11export const FormMock: FC<Props> = ({}) => {
12 const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
13 e.preventDefault();
14 login(e.currentTarget["user"].value, e.currentTarget["password"].value);
15 };
16
17 return (
18 <div>
19 <form onSubmit={handleSubmit}>
20 <label>
21 User:
22 <input
23 type="text"
24 name="user"
25 data-testid="testid"
26 placeholder="User ID"
27 />
28 </label>
29 <label>
30 Password:
31 <input
32 type="password"
33 name="password"
34 aria-label="password"
35 placeholder="Password"
36 />
37 </label>
38 <button type="submit">Submit</button>
39 </form>
40 </div>
41 );
42};

  • FormMock.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, userEvent, within } from "@storybook/test";
3import { createMock, getMock } from "storybook-addon-vite-mock";
4import { FormMock } from "./FormMock";
5import login from "./login";
6
7const meta: Meta<typeof FormMock> = {
8 tags: ["autodocs"],
9 component: FormMock,
10 parameters: {},
11 args: {},
12};
13export default meta;
14
15export const Primary: StoryObj<typeof FormMock> = {};
16
17export const Submit: StoryObj<typeof FormMock> = {
18 args: {},
19 parameters: {
20 moduleMock: {
21 mock: () => {
22 const mock = createMock(login);
23 return mock;
24 },
25 },
26 },
27 play: async ({ canvasElement, parameters }) => {
28 const mock = getMock(parameters, login);
29 const canvas = within(canvasElement);
30 const userInput = await canvas.findByLabelText("User:");
31 const passwordInput = await canvas.findByLabelText("Password:");
32 await userEvent.type(userInput, "User");
33 await userEvent.type(passwordInput, "Password");
34 await userEvent.click(await canvas.findByText("Submit"));
35 expect(mock.mock.lastCall).toStrictEqual(["User", "Password"]);
36 },
37};

戻り値の変更

  • message.ts

getMessage 関数をモック化して、戻り値を変更しています。

1export const getMessage = () => {
2 return "Before";
3};

  • LibHook.tsx

1import React, { FC, useState } from "react";
2import { getMessage } from "./message";
3
4interface Props {}
5
6/**
7 * LibHook
8 *
9 * @param {Props} { }
10 */
11export const LibHook: FC<Props> = ({}) => {
12 const [, reload] = useState({});
13 const value = getMessage();
14 return (
15 <div>
16 <button onClick={() => reload({})}>{value}</button>
17 </div>
18 );
19};

  • LibHook.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, userEvent, waitFor, within } from "@storybook/test";
3import { createMock, getMock } from "storybook-addon-vite-mock";
4import { LibHook } from "./LibHook";
5import { getMessage } from "./message";
6
7const meta: Meta<typeof LibHook> = {
8 component: LibHook,
9};
10export default meta;
11
12export const Primary: StoryObj<typeof LibHook> = {
13 play: async ({ canvasElement }) => {
14 const canvas = within(canvasElement);
15 expect(canvas.getByText("Before")).toBeInTheDocument();
16 },
17};
18
19export const Mock: StoryObj<typeof LibHook> = {
20 parameters: {
21 moduleMock: {
22 mock: () => {
23 const mock = createMock(getMessage);
24 mock.mockReturnValue("After");
25 return [mock];
26 },
27 },
28 },
29 play: async ({ canvasElement, parameters }) => {
30 const canvas = within(canvasElement);
31 expect(canvas.getByText("After")).toBeInTheDocument();
32 const mock = getMock(parameters, getMessage);
33 expect(mock).toBeCalled();
34 },
35};
36
37export const Action: StoryObj<typeof LibHook> = {
38 parameters: {
39 moduleMock: {
40 mock: () => {
41 const mock = createMock(getMessage);
42 return [mock];
43 },
44 },
45 },
46 play: async ({ canvasElement, parameters }) => {
47 const canvas = within(canvasElement);
48 const mock = getMock(parameters, getMessage);
49 mock.mockReturnValue("Action");
50 userEvent.click(await canvas.findByRole("button"));
51 await waitFor(() => {
52 expect(canvas.getByText("Action")).toBeInTheDocument();
53 });
54 },
55};

モックのリセット

  • action.ts

テストの途中でいったんモックのリセットを行っています。

1export const action1 = () => {
2 //
3};
4export const action2 = () => {
5 //
6};

  • MockReset.tsx

1import React, { FC } from "react";
2import { action1, action2 } from "./action";
3
4interface Props {}
5
6/**
7 * MockReset
8 *
9 * @param {Props} { }
10 */
11export const MockReset: FC<Props> = ({}) => {
12 return (
13 <div>
14 <button onClick={action1}>Button1</button>
15 <button onClick={action2}>Button2</button>
16 </div>
17 );
18};

  • MockReset.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, userEvent, waitFor, within } from "@storybook/test";
3import { createMock, getMock, resetMock } from "storybook-addon-vite-mock";
4import { action1, action2 } from "./action";
5import { MockReset } from "./MockReset";
6
7const meta: Meta<typeof MockReset> = {
8 component: MockReset,
9};
10export default meta;
11
12export const Primary: StoryObj<typeof MockReset> = {
13 parameters: {
14 moduleMock: {
15 mock: () => {
16 // The mock to be used is created here
17 const mock1 = createMock(action1);
18 const mock2 = createMock(action2);
19 return [mock1, mock2];
20 },
21 },
22 },
23 play: async ({ canvasElement, parameters }) => {
24 const mock1 = getMock(parameters, action1);
25 const mock2 = getMock(parameters, action2);
26
27 const canvas = within(canvasElement);
28 await waitFor(() => {
29 expect(mock1).not.toBeCalled();
30 expect(mock2).not.toBeCalled();
31 });
32 userEvent.click(await canvas.findByText("Button1"));
33 await waitFor(() => {
34 expect(mock1).toBeCalled();
35 expect(mock2).not.toBeCalled();
36 });
37
38 // Reset all mock
39 resetMock(parameters);
40 await waitFor(() => {
41 expect(mock1).not.toBeCalled();
42 expect(mock2).not.toBeCalled();
43 });
44
45 userEvent.click(await canvas.findByText("Button2"));
46 await waitFor(() => {
47 expect(mock1).not.toBeCalled();
48 expect(mock2).toBeCalled();
49 });
50 },
51};

useMemo への割り込み

React の useMemo をモック化して、戻り値を変更しています。ただ、useMemo は他のコンポーネントにも影響するので、こういう使い方は注意が必要です。

  • MockTest.tsx

1import React, { FC, useMemo, useState } from "react";
2interface Props {}
3
4/**
5 * MockTest
6 *
7 * @param {Props} { }
8 */
9export const MockTest: FC<Props> = ({}) => {
10 const [, reload] = useState({});
11 const value = useMemo(() => {
12 return "Before";
13 }, []);
14 return (
15 <div>
16 <button onClick={() => reload({})}>{value}</button>
17 </div>
18 );
19};

  • MockTest.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, userEvent, waitFor, within } from "@storybook/test";
3import { DependencyList, useMemo } from "react";
4import { createMock, getMock, getOriginal } from "storybook-addon-vite-mock";
5import { MockTest } from "./MockTest";
6
7const meta: Meta<typeof MockTest> = {
8 component: MockTest,
9};
10export default meta;
11
12export const Primary: StoryObj<typeof MockTest> = {
13 play: async ({ canvasElement }) => {
14 const canvas = within(canvasElement);
15 expect(canvas.getByText("Before")).toBeInTheDocument();
16 },
17};
18
19export const Mock: StoryObj<typeof MockTest> = {
20 parameters: {
21 moduleMock: {
22 mock: () => {
23 const mock = createMock(useMemo);
24 mock.mockImplementation((fn: () => unknown, deps: DependencyList) => {
25 const value = getOriginal(useMemo)(fn, deps);
26 return value === "Before" ? "After" : value;
27 });
28 return [mock];
29 },
30 },
31 },
32 play: async ({ canvasElement, parameters }) => {
33 const canvas = within(canvasElement);
34 expect(canvas.getByText("After")).toBeInTheDocument();
35 const mock = getMock(parameters, useMemo);
36 expect(mock).toBeCalled();
37 },
38};
39
40export const Action: StoryObj<typeof MockTest> = {
41 parameters: {
42 moduleMock: {
43 mock: () => {
44 const mock = createMock(useMemo);
45 mock.mockImplementation(getOriginal(useMemo));
46 return [mock];
47 },
48 },
49 },
50 play: async ({ canvasElement, parameters }) => {
51 const canvas = within(canvasElement);
52 const mock = getMock(parameters, useMemo);
53 mock.mockImplementation((fn: () => unknown, deps: DependencyList) => {
54 const value = getOriginal(useMemo)(fn, deps);
55 return value === "Before" ? "Action" : value;
56 });
57 userEvent.click(await canvas.findByRole("button"));
58 await waitFor(() => {
59 expect(canvas.getByText("Action")).toBeInTheDocument();
60 });
61 },
62};

強制再描画

インタラクションテスト中にコンポーネントを強制再描画します

  • message.ts

1export const getMessage = () => {
2 return "Before";
3};

  • ReRender.tsx

1import React, { FC } from "react";
2import { getMessage } from "./message";
3
4interface Props {}
5
6/**
7 * ReRender
8 *
9 * @param {Props} { }
10 */
11export const ReRender: FC<Props> = ({}) => {
12 const value = getMessage();
13 return <div>{value}</div>;
14};

  • ReRender.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, waitFor, within } from "@storybook/test";
3import { createMock, getMock, render } from "storybook-addon-vite-mock";
4import { getMessage } from "./message";
5import { ReRender } from "./ReRender";
6
7const meta: Meta<typeof ReRender> = {
8 component: ReRender,
9};
10export default meta;
11
12export const Primary: StoryObj<typeof ReRender> = {};
13
14export const ReRenderTest: StoryObj<typeof ReRender> = {
15 parameters: {
16 moduleMock: {
17 mock: () => {
18 const mock = createMock(getMessage);
19 return [mock];
20 },
21 },
22 },
23 play: async ({ canvasElement, parameters }) => {
24 const canvas = within(canvasElement);
25 const mock = getMock(parameters, getMessage);
26 mock.mockReturnValue("Test1");
27 render(parameters);
28 await waitFor(() => {
29 expect(canvas.getByText("Test1")).toBeInTheDocument();
30 });
31 mock.mockReturnValue("Test2");
32 render(parameters);
33 await waitFor(() => {
34 expect(canvas.getByText("Test2")).toBeInTheDocument();
35 });
36 },
37};

引数を設定してコンポーネントを再描画

コンポーネントを再描画する際に引数を設定して再描画します。

  • ReRenderArgs.tsx

1import React, { FC } from "react";
2import styled from "./ReRenderArgs.module.scss";
3
4interface Props {
5 value: string;
6}
7
8/**
9 * ReRenderArgs
10 *
11 * @param {Props} { value: string }
12 */
13export const ReRenderArgs: FC<Props> = ({ value }) => {
14 return <div className={styled.root}>{value}</div>;
15};

  • ReRenderArgs.stories.tsx

1import { Meta, StoryObj } from "@storybook/react";
2import { expect, waitFor, within } from "@storybook/test";
3import { render } from "storybook-addon-vite-mock";
4import { ReRenderArgs } from "./ReRenderArgs";
5
6const meta: Meta<typeof ReRenderArgs> = {
7 component: ReRenderArgs,
8 args: { value: "Test" },
9};
10export default meta;
11
12export const Primary: StoryObj<typeof ReRenderArgs> = {
13 args: {},
14 play: async ({ canvasElement, parameters, step }) => {
15 const canvas = within(canvasElement);
16
17 await step("first props", async () => {
18 expect(canvas.getByText("Test")).toBeInTheDocument();
19 });
20
21 await step("Re-render with new props", async () => {
22 // Re-render with new props
23 render(parameters, { value: "Test2" });
24 await waitFor(() => {
25 expect(canvas.getByText("Test2")).toBeInTheDocument();
26 });
27
28 // Re-render with new props
29 render(parameters, { value: "Test3" });
30 await waitFor(() => {
31 expect(canvas.getByText("Test3")).toBeInTheDocument();
32 });
33
34 // Re-render with new props
35 render(parameters, { value: "Test4" });
36 await waitFor(() => {
37 expect(canvas.getByText("Test4")).toBeInTheDocument();
38 });
39 });
40 },
41};

まとめ

Storybook + Vite のインタラクション環境下で import した関数のモックが可能になりました。これでテストを書くのが容易になります。

また、公式でもモジュールモックの動きはあるので、いずれ今回作ったアドオンが不要になることを願っています。

https://github.com/storybookjs/storybook/issues/26637