🧩

Storybook と Vitest で userEvent 取り違えをなくす

に公開

こんにちは Social PLUS のフロントエンドエンジニアのまっくすです。

先日、Storybook を v7 → v8 にアップデートをしていた際に、Vitest のテストファイルに Storybook 用のモジュール@storybook/testing-library が紛れていたことに気がつきました。
Storybook のアップデートに際して、@storybook/testing-library@storybook/test に統合するために、@storybook/testing-librarydevDependencies から削除したことで、Lintエラーが発生しました。

Copilot や IDE の補完の精度が良くなったため、何も考えずにインポートの補完をしていることが多いです(自分だけかもしれないが)。おそらく、@storybook/testing-library が紛れていたのも、何も考えずにインポートをしていたことが原因かと思われます。

影響の少ない些細なミスですが、仕組みで解決できるので、小ネタとして紹介できればと思っています!

対象

Vitest(または Jest) や Storybook を利用して開発している人

前提

弊社の現在の技術スタック

  • Next.js v15
  • Vitest v2
  • @testing-library/react v16
  • @testing-library/user-event v14
  • Storybook v8

なぜ取り違えが起きるか

最初にも書きましたが、Cursor や VS Code で Copilot のサジェストは優秀なものの、完璧ではありません。

Vitest のテストファイル で @storybook/testuserEvent がサジェストされる。

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

本当であれば文脈に応じてサジェストするライブラリの順番を変更してくれたらいいのですが、現状ではそうはいきません。
誤ったサジェストを何も考えずに確定すると、異なる文脈のモジュールが混入する可能性があります。ですので、人間が対策をする他ないです。

次の章からは、Storybook やテストでどのように対策していくかについて解説していきます。

結論

早速結論です。
結論としては👇の通りです。

  • Storybookでは "plugin:storybook/recommended" を有効にする
  • (可能なら)Storybook v9 へ上げ、play の引数 userEvent を受け取って使う(v8 であれば @storybook/testuserEvent を import する)
  • テストでは userEvent を直接インポートせず、@testing-library/reactrender 関数をカスタマイズして userEvent.setup() 済みの user を返すようにする

イメージがつきにくいと思いますので、具体的にみていきましょう。

Storybook での対策

Storybook では plugin:storybook/recommendedextends に入れるだけで Lint エラーが出るようになります。

参考
https://storybook.js.org/docs/configure/integration/eslint-plugin#installation

.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-event directly in the story. You should import the functions from @storybook/testing-library instead.eslintstorybook/use-storybook-testing-library

また、Storybook v9 以上であれば、userEvent を 直接インポートせず、play の引数から受け取るようにするのが公式の書き方になっているので、公式の書き方に倣うことで対策できます。

参考
https://storybook.js.org/docs/writing-stories/play-function#writing-stories-with-the-play-function

// 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 エラーが出るようになります。

参考
https://eslint.org/docs/latest/rules/no-restricted-imports

.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-event v14 から userEvent.setup() を使った書き方が導入され、推奨となった
  • テストファイルでその都度 userEvent.setup() するのが面倒
  • テストファイルでその都度 render(<Component />, { wrapper: TestWrapper }); のように wrapper を指定するのが面倒

このような理由から、 @testing-library/reactrender を独自にカスタマイズしたものを弊社では利用しています。使い勝手もいいのでおすすめです。次の章では具体的なカスタマイズの例を解説していきます。

参考
https://testing-library.com/docs/react-testing-library/api/#render
https://testing-library.com/docs/user-event/setup
https://qiita.com/b-yuko/items/89fa4a85e5ea0a7de8d5

render 関数をカスタマイズ

公式ドキュメントの例を参考にして、@testing-library/reactrender 関数のように使えるカスタムの render 関数を用意しました。
こうすることで、コードを省略でき、userEvent のインポートの補完ミスも構造的に防げます。

https://testing-library.com/docs/user-event/intro/#writing-tests-with-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 関数のカスタマイズなどはどのテストにも共通するものなので、効果があっておすすめです!

参考
https://kentcdodds.com/blog/avoid-nesting-when-youre-testing#apply-aha-avoid-hasty-abstractions
https://storybook.js.org/docs/releases/migration-guide-from-older-version
https://testing-library.com/docs/user-event/intro/

弊社のフロントエンドのテスト方針についての参考記事もおすすめなので、ぜひご一読ください。
https://zenn.dev/socialplus/articles/b09827d74ff148

Social PLUS Tech Blog

Discussion