不可能を可能に!StorybookでModuleモックをしてフロントテストを便利にする
モジュールモックまでの流れ
標準機能ではモジュールモックが出来ない
以前、以下のような記事を書きました。
Storybook を使用したテストは非常に開発しやすいのですが、致命的とも言える欠点があります。それは、コンポーネント内でインポートした関数のモックを作成できないということです。class であれば、jest.spyOn を使用して割り込むことができますが、関数には干渉できないため、モック化することができません。
公式だと Webpack のエイリアスが説明されている
公式ドキュメントには、Webpack のエイリアスについて説明されており、解決策としてファイルごとにエイリアスを設定し、入れ替えることが紹介されています。
ただし、複数のモックを作成する場合は手間がかかり、非常に面倒な作業となります。このような状況では、息をするのも面倒くさく感じることがあるかもしれません。
やりたいのは jest.mock
Jest を使用してネイティブでテストを行う場合は、jest.mock
を使用することで問題を解決できます。ただし、@storybook/addon-interactions
を使用してテストを行う場合は、jest.mock を使用することはできません。実際の問題は、Storybook でビルドされたプログラムがエクスポートされたモジュールに対して、外部から割り込むことができないことです。これらのモジュールは、上書きしようとしても読み取り専用 (Readonly) であるため、変更することができません。
無い、ならば作ろう
インターネット上で関連する Addon を探しましたが、Webpack のエイリアスを便利にするものや、API サーバを立ち上げてモックを行うものなどはありましたが、jest.mock のようにモジュールに都度割り込む機能を提供する Addon は見つかりませんでした。最近話題の AI にも質問してみましたが、トンチンカンな回答しか得られず、残念な結果に終わりました。
しかし、なければ自分で作れば良いということで、制作に着手しました。
普通にやっても無理
そもそものところで、Storybook 上では正攻法で外部モジュールに割り込むことは機能的に不可能です。インポートしたインスタンスの各機能は getter で参照するようになっており、書き込み不可能です。しかし、普通にやって無理ならば穴を開ければよいのです。そこで、Babel プラグインを作って、ビルド時にモジュールに割り込み用の穴を作るようにしました。適当に作っていたら穴開けに成功し、あっさりと外部から割り込めるようになりました。
次に、Storybook から簡単に利用するための Addon を作りました。割り込むタイミングと復元制御が簡単に行えるように、Decorator 上で制御するようにしました。
Babel プラグインや Storybook の Addon は今まで手を出したことがありませんでしたが、片手間に作って2日で出来上がりました。
出来上がったと思ったら外部モジュールに割り込めなかった
Storybook 上で Babel プラグインを動作させても、干渉できるのは自分のプロジェクト内のファイルだけです。外部モジュールには干渉できていませんでした。ということで今度は Webpack のプラグインを作って、getter 制限を取り外すことにしました。
Webpack から割り込んだ結果
Storybook 上で Babel プラグインを動作させても、干渉できるのは自分のプロジェクト内のファイルだけで、外部モジュールには干渉できませんでした。そこで、今度は Webpack のプラグインを作って、getter 制限を取り外すことにしました。しかし、何故か static ビルドした時にローカルモジュールが割り込めず、仕方がないので、Babel でローカル、Webpack で外部モジュールの割り込みという処理にしました。その他、dev 起動時の挙動の違いなどもありましたが、全て対策を取りました。
適当に作っていたら出来上がる
毎度のことですが、余り深く考えずに適当に作っていると出来上がってしまいます。Babel や Webpack のプラグインなどは作ったことはありませんでしたが、おそらく大切なのは勘の良さだけだと思います。
出来上がったもの
使い方
addons の追加
モジュールをインストールして Storybook の addons に追加します。
.storybook/main.js
// @ts-check
/**
* @type { import("@storybook/react/types").StorybookConfig}
*/
module.exports = {
addons: ["storybook-addon-module-mock"],
};
React の hook をモックしてみる
https://sorakumo001.github.io/storybook-module-mock/?path=/story/components-mocktest--primary
useMemo
をモックして Before と After を切り替えるサンプルです。
インタラクションテスト内で Action への切り替えも行っています。
MockTest.tsx
import React, { FC, useMemo, useState } from "react";
interface Props {}
/**
* MockTest
*
* @param {Props} { }
*/
export const MockTest: FC<Props> = ({}) => {
const [, reload] = useState({});
const value = useMemo(() => {
return "Before";
}, []);
return (
<div>
<button onClick={() => reload({})}>{value}</button>
</div>
);
};
MockTest.stories.tsx
import { expect } from "@storybook/jest";
import { userEvent, waitFor, within } from "@storybook/testing-library";
import { createMock, getMock } from "storybook-addon-module-mock";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { MockTest } from "./MockTest";
import React from "react";
const meta: ComponentMeta<typeof MockTest> = {
title: "Components/MockTest",
component: MockTest,
};
export default meta;
export const Primary: ComponentStoryObj<typeof MockTest> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Before")).toBeInTheDocument();
},
};
export const Mock: ComponentStoryObj<typeof MockTest> = {
parameters: {
moduleMock: {
mock: () => {
const mock = createMock(React, "useMemo");
mock.mockReturnValue("After");
return [mock];
},
},
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("After")).toBeInTheDocument();
const mock = getMock(parameters, React, "useMemo");
expect(mock).toBeCalled();
},
};
export const Action: ComponentStoryObj<typeof MockTest> = {
parameters: {
moduleMock: {
mock: () => {
const mock = createMock(React, "useMemo");
return [mock];
},
},
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
const mock = getMock(parameters, React, "useMemo");
mock.mockReturnValue("Action");
userEvent.click(await canvas.findByRole("button"));
await waitFor(() => {
expect(canvas.getByText("Action")).toBeInTheDocument();
});
},
};
出力画面
ローカルモジュールのモック
https://sorakumo001.github.io/storybook-module-mock/?path=/story/components-libhook--primary
getMessage
をモックして Before と After を切り替えるサンプルです。
message.ts
export const getMessage = () => {
return "Before";
};
LibHook.tsx
import React, { FC, useState } from "react";
import { getMessage } from "./message";
interface Props {}
/**
* LibHook
*
* @param {Props} { }
*/
export const LibHook: FC<Props> = ({}) => {
const [, reload] = useState({});
const value = getMessage();
return (
<div>
<button onClick={() => reload({})}>{value}</button>
</div>
);
};
LibHook.stories.tsx
import { expect } from "@storybook/jest";
import { userEvent, waitFor, within } from "@storybook/testing-library";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { LibHook } from "./LibHook";
import { createMock, getMock } from "storybook-addon-module-mock";
import * as message from "./message";
const meta: ComponentMeta<typeof LibHook> = {
title: "Components/LibHook",
component: LibHook,
};
export default meta;
export const Primary: ComponentStoryObj<typeof LibHook> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Before")).toBeInTheDocument();
},
};
export const Mock: ComponentStoryObj<typeof LibHook> = {
parameters: {
moduleMock: {
mock: () => {
const mock = createMock(message, "getMessage");
mock.mockReturnValue("After");
return [mock];
},
},
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("After")).toBeInTheDocument();
const mock = getMock(parameters, message, "getMessage");
expect(mock).toBeCalled();
},
};
export const Action: ComponentStoryObj<typeof LibHook> = {
parameters: {
moduleMock: {
mock: () => {
const mock = createMock(message, "getMessage");
return [mock];
},
},
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
const mock = getMock(parameters, message, "getMessage");
mock.mockReturnValue("Action");
userEvent.click(await canvas.findByRole("button"));
await waitFor(() => {
expect(canvas.getByText("Action")).toBeInTheDocument();
});
},
};
next/link のモック化
https://sorakumo001.github.io/storybook-module-mock/?path=/story/components-nexthook--primary
Link
コンポーネントを別の内容に置き換えます。
NextHook.tsx
import Link from "next/link";
import React, { FC } from "react";
interface Props {}
/**
* NextHook
*
* @param {Props} { }
*/
export const NextHook: FC<Props> = ({}) => {
return (
<div>
<Link href="/">Before</Link>
</div>
);
};
NextHook.stories.tsx
import { expect } from "@storybook/jest";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { within } from "@storybook/testing-library";
import * as link from "next/link";
import { createMock, getMock } from "storybook-addon-module-mock";
import { NextHook } from "./NextHook";
const meta: ComponentMeta<typeof NextHook> = {
title: "Components/NextHook",
component: NextHook,
parameters: {
// nextRouter: { asPath: '/' },
},
args: {},
};
export default meta;
export const Primary: ComponentStoryObj<typeof NextHook> = {};
export const Mock: ComponentStoryObj<typeof NextHook> = {
parameters: {
moduleMock: {
mock: () => {
const mock = createMock(link);
mock.mockReturnValue(<div>After</div>);
return [mock];
},
},
},
play: async ({ canvasElement, parameters }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("After")).toBeInTheDocument();
const mock = getMock(parameters, link);
expect(mock).toBeCalled();
},
};
まとめ
Storybook でのモジュールのモック化が可能になったことにより、従来の jest で書いていたテストをインタラクションテストに置き換えやすくなりました。また、Story に表示するデータの切り替えが簡単になるため、サンプリングも容易になります。Storybook には今後も様々な機能が追加される予定ですので、フロントエンドのテストにおいて、Storybook のインタラクションテストが広く使われる可能性があるでしょう。
Discussion
初めまして。
こちらのプラグインについてですが、jest を用いたテストを実施せずに、特定の hooks をモック化することは可能でしょうか?
例えば、Mypageコンポーネントのストーリーにおいて、useMemo をモック化して 0 を返すようにしたいと考えています。
補足:
上記の方法を試してみましたが、以下のエラーが発生しました。
jest を使用せずに特定の hooks をモック化することが可能であれば、追加のプラグインのインストールや設定が必要かどうか教えていただけると助かります。
よろしくお願いいたします。
サンプルがあるので、そちらを確認してください。