📌

不可能を可能に!StorybookでModuleモックをしてフロントテストを便利にする

2023/03/13に公開2

モジュールモックまでの流れ

標準機能ではモジュールモックが出来ない

以前、以下のような記事を書きました。

https://zenn.dev/sora_kumo/articles/8a79531e726b29

Storybook を使用したテストは非常に開発しやすいのですが、致命的とも言える欠点があります。それは、コンポーネント内でインポートした関数のモックを作成できないということです。class であれば、jest.spyOn を使用して割り込むことができますが、関数には干渉できないため、モック化することができません。

公式だと Webpack のエイリアスが説明されている

公式ドキュメントには、Webpack のエイリアスについて説明されており、解決策としてファイルごとにエイリアスを設定し、入れ替えることが紹介されています。

https://storybook.js.org/docs/7.0/react/writing-stories/build-pages-with-storybook#mocking-imports

ただし、複数のモックを作成する場合は手間がかかり、非常に面倒な作業となります。このような状況では、息をするのも面倒くさく感じることがあるかもしれません。

やりたいのは 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 のプラグインなどは作ったことはありませんでしたが、おそらく大切なのは勘の良さだけだと思います。

出来上がったもの

https://www.npmjs.com/package/storybook-addon-module-mock

使い方

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();
    });
  },
};

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 のインタラクションテストが広く使われる可能性があるでしょう。

GitHubで編集を提案

Discussion

SyunKiSyunKi

初めまして。
こちらのプラグインについてですが、jest を用いたテストを実施せずに、特定の hooks をモック化することは可能でしょうか?
例えば、Mypageコンポーネントのストーリーにおいて、useMemo をモック化して 0 を返すようにしたいと考えています。

export const Mypage = () => {
  const hoge = useMemo(() => {
    return 100;
  }, []);

  return <div>マイページ:{hoge}</div>;
};
export const Primary: Story = {
  args: {},
  parameters: {
    moduleMock: {
      mock: () => {
        const mock = createMock(React, 'useMemo');
        mock.mockReturnValue(0);

        return [mock];
      },
    },
  },
};

補足:
上記の方法を試してみましたが、以下のエラーが発生しました。
jest を使用せずに特定の hooks をモック化することが可能であれば、追加のプラグインのインストールや設定が必要かどうか教えていただけると助かります。

Cannot find module 'storybook/internal/preview-api'

The component failed to render properly, likely due to a configuration issue in Storybook. Here are some common causes and how you can address them:

よろしくお願いいたします。