sb.mockでStorybookで利用するモジュールをモックしよう!
Storybook v9.1.0で新たに導入されたsb.mock機能により、モジュールのモックがこれまで以上に簡単に行えるようになりました。sb.mockはStorybookを活用したテストの取り組みやすさを大幅に向上させる画期的な新機能です。ぜひ活用してみてください。
はじめに
Storybookのv9.1.0でsb.mock
を用いてモジュールをモックするAutomocking
という機能が追加されました。
Loading...
Loading...
github.com
従来のStorybookで主流だった、サブパスインポートやVite
・Webpack
のエイリアス機能を利用した手法を置き換えるような機能となっています(サブパスインポートを用いたモックについて)。
sb.mockを使ってみる
sb
はオブジェクトで、storybook/test
からインポートできます。
import { sb } from 'storybook/test';
v9.1.0時点ではmock
メソッドのみが公開されています。
mock
メソッドは第一引数にモックするモジュールのパス、第二引数にオプションをオブジェクトで指定します。
// オプション無し
sb.mock('./src/services/users');
// オプション有り
sb.mock('./src/services/users', {
spy: true,
});
オプションはv9.1.0時点ではspy
のみがサポートされています。
mockしてみる
sb.mock
の第一引数に渡したモジュールをモックします。
// preview.ts
sb.mock('./src/services/users');
sb.mock
はグローバルなモックなので定義可能な箇所はpreview.ts(x)
のみであることに注意してください。
デフォルトの挙動
デフォルトの状態、またはspyをfalseに設定した場合、モックしたモジュールのメソッドを呼び出しても、そのメソッドの実際の処理は実行されません。
// preview.ts
sb.mock('./src/services/users', {
spy: false,
});
// src/services/users.ts
console.log('これは呼ばれちゃいます');
export const getUser = () => {
return { id: '1', name: 'John Doe' };
};
上記のように定義されている場合、Storyに表示するコンポーネント内でgetUser
メソッドを呼び出しても、users.ts
に定義された元のgetUser
は実行されません。代わりにfn()
が返されます。
ただし、元のメソッドが記述されたusers.ts
ファイル自体は読み込まれるので、例のconsole.log
のようなグローバルに定義された宣言は実行されることに注意してください。
モックしたモジュールのメソッドをspyする
spy
をtrue
に設定すると、モックしたモジュールのメソッドを呼び出した際に、元の関数の処理が実際に実行されます。
// preview.ts
sb.mock('./src/services/users', {
spy: true,
});
// src/services/users.ts
// この関数がそのまま使われる。
export const getUser = async (id: number) => {
const user = await User.findById(id);
if (!user) {
return null;
}
return { id: user.id, name: user.name };
};
モックしたモジュールのメソッドはモック関数としても記録され、呼ばれた回数や引数を検証ができます。
モックしたモジュールのメソッドを検証する
デフォルトの挙動ではメソッドの戻り値がないため、何かしらの値を返すようにしたことが多いです。spyした場合であっても(そうでなくても)、検証のためにモックしたメソッドを制御したいです。
そのような場合はストーリーのbeforeEach
でmocked
関数を用いて制御できます。
import { Meta, StoryObj } from '@storybook/react-vite';
import { expect, mocked } from 'storybook/test';
import { User } from './user';
import { getUser } from '@/services/users';
const meta = {
title: 'user',
component: User,
beforeEach: async () => {
mocked(getUser).mockResolvedValue({
id: '1',
name: 'John Doe',
});
},
} satisfies Meta<typeof User>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async () => {
expect(getUser).toHaveBeenCalledWith(1);
},
};
export const NotFound: Story = {
beforeEach: async () => {
mocked(getUser).mockResolvedValue(null);
},
play: async ({ canvas, userEvent }) => {
expect(getUser).toHaveBeenCalledWith(1);
// ...
// ...
expect(user).toBeNull();
},
};
storybook/test
のmocked
関数にモックしたモジュールのメソッドを渡すことで、モックしたメソッドを制御できます。
StorybookのbeforeEach
の単位で制御できるので便利ですね。
mocked
関数はVitest
のMaybeMocked
等を返すので、Vitest
のMock Functions
で利用可能なすべてのメソッドを利用可能です。
別のファイルを呼び出す
sb.mock
はデフォルトでモックしたモジュールのグローバルな宣言は実行されると前述しました。
サーバーでのみ動くような宣言がグローバルに含まれていたり、他のいくつかのケースではモック先のファイルを一切呼び出して欲しくないケースもあるでしょう。 その場合はモック専用のファイルを用意することで、モジュールごと置き換えられます。
手元のファイルのモック
src/services/users.ts
のような手元のファイルをモックしたい場合は、src/services/__mocks__/users.ts
のように同一の階層に__mocks__
ディレクトリを作成し、その中にモックしたいファイルと同じ名前のファイルを作成します。
// src/services/users.ts
console.log('呼ばれなくなりました');
export const getUser = async (id: number) => {
const user = await User.findById(id);
if (!user) {
return null;
}
return { id: user.id, name: user.name };
};
// src/services/__mocks__/users.ts
export const getUser = () => {
return { id: '1', name: 'Mocked User' };
};
ライブラリのモック
node_modules
から呼び出すようなパッケージのモジュールをモックしたい場合は、プロジェクトのルートに__mocks__
ディレクトリを作成し、その中にモックしたいファイルと同じ名前のファイルを作成します。
// __mocks__/react.ts
import { FC, PropsWithChildren } from 'react';
export const ViewTransition: FC<PropsWithChildren> = ({ children }) =>
children;
プロジェクトのルートは.git
がある位置がデフォルトです。STORYBOOK_PROJECT_ROOT
環境変数を設定することで、プロジェクトのルートを変更できます。
おわりに
従来のalias
を用いたモックでは、きめ細かい制御が難しく、思うような実装ができないことがありました。
サブパスインポートによるモックは詳細な制御が可能で便利でしたが、TypeScriptのpaths
設定と競合し、インポートの解決でトラブルが多くストレスを感じる場面がありました。
新しく登場したsb.mock
は、これらの問題を解決してくれます。現在のところ何のストレスもなく利用でき、まさに求めていた機能を手に入れることができました。
近年のStorybookはテスト機能が大きく充実し、これまで手が届かなかったテストも簡単に書けるようになるなど、開発者にとって非常に価値のある機能が次々と追加されています。
今後もStorybookのさらなる進化に注目していきたいです。