Storybook と Vitest で userEvent 取り違えをなくす
こんにちは Social PLUS のフロントエンドエンジニアのまっくすです。
先日、Storybook を v7 → v8 にアップデートをしていた際に、Vitest のテストファイルに Storybook 用のモジュール@storybook/testing-library が紛れていたことに気がつきました。
Storybook のアップデートに際して、@storybook/testing-library を @storybook/test に統合するために、@storybook/testing-library を devDependencies から削除したことで、Lintエラーが発生しました。
Copilot や IDE の補完の精度が良くなったため、何も考えずにインポートの補完をしていることが多いです(自分だけかもしれないが)。おそらく、@storybook/testing-library が紛れていたのも、何も考えずにインポートをしていたことが原因かと思われます。
影響の少ない些細なミスですが、仕組みで解決できるので、小ネタとして紹介できればと思っています!
対象
Vitest(または Jest) や Storybook を利用して開発している人
前提
弊社の現在の技術スタック
-
Next.jsv15 -
Vitestv2 -
@testing-library/reactv16 -
@testing-library/user-eventv14 -
Storybookv8
なぜ取り違えが起きるか
最初にも書きましたが、Cursor や VS Code で Copilot のサジェストは優秀なものの、完璧ではありません。
Vitest のテストファイル で @storybook/test の userEvent がサジェストされる。

また、Storybook でも同様に @testing-library/user-event の userEvent がサジェストされる

本当であれば文脈に応じてサジェストするライブラリの順番を変更してくれたらいいのですが、現状ではそうはいきません。
誤ったサジェストを何も考えずに確定すると、異なる文脈のモジュールが混入する可能性があります。ですので、人間が対策をする他ないです。
次の章からは、Storybook やテストでどのように対策していくかについて解説していきます。
結論
早速結論です。
結論としては👇の通りです。
- Storybookでは
"plugin:storybook/recommended"を有効にする - (可能なら)Storybook v9 へ上げ、
playの引数userEventを受け取って使う(v8 であれば@storybook/testのuserEventを import する) - テストでは
userEventを直接インポートせず、@testing-library/reactのrender関数をカスタマイズしてuserEvent.setup()済みのuserを返すようにする
イメージがつきにくいと思いますので、具体的にみていきましょう。
Storybook での対策
Storybook では plugin:storybook/recommended を extends に入れるだけで Lint エラーが出るようになります。
参考
.eslintrc.js
module.exports = {
extends: [
// ... そのほかのプラグイン
'plugin:storybook/recommended',
],
rules: {
// ... その他のルール
},
};
plugin:storybook/recommended 設定後
Storybook で userEvent を @testing-library/user-event からインポートした箇所で、以下のようにESLint のエラーが吐かれています。
Do not use
@testing-library/user-eventdirectly in the story. You should import the functions from@storybook/testing-libraryinstead.eslintstorybook/use-storybook-testing-library

また、Storybook v9 以上であれば、userEvent を 直接インポートせず、play の引数から受け取るようにするのが公式の書き方になっているので、公式の書き方に倣うことで対策できます。
参考
// before
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent } from '@testing-library/user-event'; \\ 🙅🏻♂️ Story 側で testing-library の userEvent を import している
// after
import type { Meta, StoryObj } from '@storybook/react';
export const FilledForm: Story = {
// play の引数から userEvent を受け取り、それを使う(自前 import しない)
play: async ({ canvas, userEvent }) => {
// ...
},
};
テストでの対策
ESLint の no-restricted-imports を設定することで Lint エラーが出るようになります。
参考
.eslintrc.js
module.exports = {
overrides: [
// ...その他のルール
{
files: ['*.spec.{ts,tsx}'],
rules: {
// ... その他のルール
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@storybook/test',
importNames: ['userEvent'],
message:
'@storybook/test から userEvent を import しないでください。',
},
],
},
],
},
},
],
};
no-restricted-imports 設定後
Vitest で userEvent を @storybook/test からインポートした箇所で、以下のようにESLint のエラーが吐かれています。
'userEvent' import from '@storybook/test' is restricted. @storybook/test から userEvent を import しないでください。eslintno-restricted-imports

これでもいいのですが、
-
@testing-library/user-eventv14 からuserEvent.setup()を使った書き方が導入され、推奨となった - テストファイルでその都度
userEvent.setup()するのが面倒 - テストファイルでその都度
render(<Component />, { wrapper: TestWrapper });のようにwrapperを指定するのが面倒
このような理由から、 @testing-library/react の render を独自にカスタマイズしたものを弊社では利用しています。使い勝手もいいのでおすすめです。次の章では具体的なカスタマイズの例を解説していきます。
参考
render 関数をカスタマイズ
公式ドキュメントの例を参考にして、@testing-library/react の render 関数のように使えるカスタムの render 関数を用意しました。
こうすることで、コードを省略でき、userEvent のインポートの補完ミスも構造的に防げます。
testHelper/render.tsx
/**
* コンポーネントテストで使う render 関数
*
* Testing Library の render をラップして、以下を追加したもの
*
* - デフォルトの wrapper として TestWrapper を指定する
* - userEvent.setup() をあわせて実行する
*
* @returns render の戻り値に加えて、userEvent.setup() の戻り値 user を追加したオブジェクト
*/
export const render = (
ui: React.ReactNode,
options?: RenderOptions,
): RenderResult & {
readonly user: ReturnType<typeof userEvent.setup>;
} => ({
...originalRender(ui, {
wrapper: TestWrapper, // テストごとに毎回書いていた wrapper を指定してしまう
...options,
}),
user: userEvent.setup(), // userEvent.setup()済みの user を返す
});
利用側
// Before
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestWrapper } from '../../testHelper/wrapper';
it('ダミーのテスト', async () => {
const user = userEvent.setup();
render(<Component />, { wrapper: TestWrapper });
await user.click(...);
});
// After
import { render } from '../../testHelper/render';
it('ダミーのテスト', async () => {
const { user } = render(<Component />);
await user.click(...);
});
テストごとに書くお決まりのコードがなくなりスッキリしました!
おわりに
最後までお読みいただき、ありがとうございました!
改善系の小ネタでしたが、いかがだったでしょうか?
テストの抽象化は慎重に行う方が良いという話もありますが、render 関数のカスタマイズなどはどのテストにも共通するものなので、効果があっておすすめです!
参考
弊社のフロントエンドのテスト方針についての参考記事もおすすめなので、ぜひご一読ください。
Discussion