Storybook + Vite + React のインタラクションテストで外部モジュールモックする
@storybook/nextjs使用時のインタラクションテストとモジュールモック
Storybook でインタラクションテストを使用すると、コンポーネントの動作を GUI で確認しつつテストを書くことが出来ます。Jest や Vitest の CUI 上で表示確認無しでテストを書くのに比べると、圧倒的に楽にテストを書くことが可能です。ただし問題があって、Jest や Vitest から専用コマンドでテストを呼び出すときには、依存パッケージなどを関数単位で簡単にモック化できますが、Storybook のインタラクションテストではそうではありません。以下のようなファイル単位でのモックが必要となります。
https://storybook.js.org/blog/type-safe-module-mocking/
関数単位でのモック作成は、以前に@storybook/nextjs用に Webpack と Babel の挙動をカスタマイズしてstorybook-addon-module-mockを作りました。これを使えば import した外部モジュール内の関数をモック化することが可能です。公式ドキュメントにも紹介されています。ただし、Webpack の使用が必須となるため、Vite を使用している場合は使用できません。
@storybook/react-vite使用時のインタラクションテストとモジュールモック
@storybook/react-viteはその名の通り Vite を経由してモジュールバンドルが行われます。ということで今回、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";34interface Props {}56/**7 * FormMock8 *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 };1617 return (18 <div>19 <form onSubmit={handleSubmit}>20 <label>21 User:22 <input23 type="text"24 name="user"25 data-testid="testid"26 placeholder="User ID"27 />28 </label>29 <label>30 Password:31 <input32 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";67const meta: Meta<typeof FormMock> = {8 tags: ["autodocs"],9 component: FormMock,10 parameters: {},11 args: {},12};13export default meta;1415export const Primary: StoryObj<typeof FormMock> = {};1617export 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";34interface Props {}56/**7 * LibHook8 *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";67const meta: Meta<typeof LibHook> = {8 component: LibHook,9};10export default meta;1112export const Primary: StoryObj<typeof LibHook> = {13 play: async ({ canvasElement }) => {14 const canvas = within(canvasElement);15 expect(canvas.getByText("Before")).toBeInTheDocument();16 },17};1819export 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};3637export 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";34interface Props {}56/**7 * MockReset8 *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";67const meta: Meta<typeof MockReset> = {8 component: MockReset,9};10export default meta;1112export const Primary: StoryObj<typeof MockReset> = {13 parameters: {14 moduleMock: {15 mock: () => {16 // The mock to be used is created here17 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);2627 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 });3738 // Reset all mock39 resetMock(parameters);40 await waitFor(() => {41 expect(mock1).not.toBeCalled();42 expect(mock2).not.toBeCalled();43 });4445 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 {}34/**5 * MockTest6 *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";67const meta: Meta<typeof MockTest> = {8 component: MockTest,9};10export default meta;1112export const Primary: StoryObj<typeof MockTest> = {13 play: async ({ canvasElement }) => {14 const canvas = within(canvasElement);15 expect(canvas.getByText("Before")).toBeInTheDocument();16 },17};1819export 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};3940export 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";34interface Props {}56/**7 * ReRender8 *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";67const meta: Meta<typeof ReRender> = {8 component: ReRender,9};10export default meta;1112export const Primary: StoryObj<typeof ReRender> = {};1314export 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";34interface Props {5 value: string;6}78/**9 * ReRenderArgs10 *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";56const meta: Meta<typeof ReRenderArgs> = {7 component: ReRenderArgs,8 args: { value: "Test" },9};10export default meta;1112export const Primary: StoryObj<typeof ReRenderArgs> = {13 args: {},14 play: async ({ canvasElement, parameters, step }) => {15 const canvas = within(canvasElement);1617 await step("first props", async () => {18 expect(canvas.getByText("Test")).toBeInTheDocument();19 });2021 await step("Re-render with new props", async () => {22 // Re-render with new props23 render(parameters, { value: "Test2" });24 await waitFor(() => {25 expect(canvas.getByText("Test2")).toBeInTheDocument();26 });2728 // Re-render with new props29 render(parameters, { value: "Test3" });30 await waitFor(() => {31 expect(canvas.getByText("Test3")).toBeInTheDocument();32 });3334 // Re-render with new props35 render(parameters, { value: "Test4" });36 await waitFor(() => {37 expect(canvas.getByText("Test4")).toBeInTheDocument();38 });39 });40 },41};
まとめ
Storybook + Vite のインタラクション環境下で import した関数のモックが可能になりました。これでテストを書くのが容易になります。