🛥️

StorybookがViteと手を組みフロントテストの覇権を取りにきた

に公開

要約

  • Storybookがバージョン8以降から進化が凄まじい!
  • @storybook/experimental-nextjs-viteで、Next.JS環境でStorybookブラウザが爆速で立ち上がる!
  • 既存のVitestと統合されてテストのしやすさレベルアップ!testファイル内の普通のテストと一緒にテスト実行されるやん!
  • ブラウザ上からもコンポーネントテストできるようになってて便利だしモダンでかっこいい!

はじめに

こんにちは!イノベーション開発チームのmiyaken85です!
弊社が運営するITトレンドのフロントエンドでは、コンポーネント管理ツールとしてStorybookを使用しています。
そんなStorybookが、最近viteと手を組んでvitestでテストが行えるようになり、これまでのフロントテストの手法を激変させそうなので、今回はそのことについて、実際にITトレンドに導入しながら書きたいと思います。

Storybookとは?

https://storybook.js.org/

コンポーネントをカタログのようにまとめておくことができるツールです。コンポーネントを「Story」という単位でまとめると、それぞれのコンポーネントをブラウザから確認することができます。

story

Storyとは、「特定のデータを与えたコンポーネントの状態」であり、1つのコンポーネントに対し複数のstoryが存在し得ます。

ex. Buttonコンポーネントに対し、ButtonがcheckableなStoryと、disableなStoryを持つことができる。

story-state
以下の記事の図を参照

https://zenn.dev/fullyou/articles/853b77a3ce9144

Storybookをコンポーネントごとに作成しておくと、

  • そのアプリで使われている、作られているコンポーネントが視覚的に把握でき、コンポーネントの重複作成予防になる
  • コンポーネントの機能ごとにStoryを作成しておくことで、そのコンポーネントがもつ役割をチームで共有しやすくなる
  • デプロイすることで誰でも閲覧でき、他部署とのデザイン面機能面のコミュニケーションがしやすくなる

といったようなメリットがありますね。

StorybookでUIテストやインタラクションテストが自動で行える

上記に加えてフロントエンジニアがStorybookを採用する理由で一番大きいのは、一部フロントエンドテストが簡単に行えるという点ではないでしょうか。
Storybookには、テストのためのアドオンがいくつかあり、それらを用いることで、アクセシビリティテストやコンポーネントテスト、ビジュアルテストやスナップショットテストが簡単に作成できます。
https://storybook.js.org/docs/writing-tests

加えてtest runnerという、jestとplaywrightで動くアドオンを用いてStorybookブラウザを立ち上げて、自動でテストを行えるようになってました。
https://storybook.js.org/docs/writing-tests/test-runner

ただこの方法は、npm run Storybook等で事前にブラウザを立ち上げておく必要がありました。

このためテスト実行に毎回オーバーヘッドが発生したりするし、テスト速度が遅くなりがちでした。
テストが遅いとCIに組み込みにくくなってしまうので、そこも難点ですね😩

今回、Vitestと手を組んだことにより、この懸念点は一瞬で吹き飛ぶことになります😏

最近何が変わったのか

個人的激アツ目玉アップデートを紹介します。

コンパイラーがWebpack → Viteになった

Storybookのコンパイルツールは、これまでWebpackでしたが、新しいアドオンツールである、@storybook/experimental-nextjs-viteを用いることによって、webpackが排除され、vite環境で立ち上がるようになりました。
これによりStorybookブラウザの立ち上げ時間が大幅に短縮されます。
また後述するvitestとの相性がいいため、テストもより爆速で実行されるようになりました。

Vitest + Playwrightで、テスト爆速実行が可能に

テストで用いるツールは今までjestだったところ、@storybook/experimental-addon-testアドオンを用いることにより、Vitestが使えるようになりました。
https://vitest.dev/
加えてPlaywrightと組み合わせることで、ブラウザモードでVitestを実行することができます。
ブラウザモードでは、コンポーネントが実際のブラウザ環境でテストされるため、JSDomやHappyDom(ブラウザ環境をNode.js内で模倣してくれるライブラリ)のようなシミュレーションよりも正確性のあるテストを行うことができます。ブラウザのAPIや機能に依存するコンポーネントをテストする場合には尚更正確でありがたいですね!

ただ、これだけ聴くと、前述したtest runnerの時と同じく、ブラウザを立ち上げているから事前にStorybookブラウザをnpm run Storybook等で事前に起動しておく必要があるのではと思うかもしれません。

しかしその必要はありません。このアドオンでは、従来の仕組みとは違い、
ViteでStoryを直接Vitest用テストコードに変換し、ヘッドレスモード(裏側で実ブラウザを立ち上げるモード)でテストしているため、
いちいちStorybookをブラウザで立ち上げる必要がなくなったんです🕺🏻

ぶっちゃけ内部の仕組みまでコードを追いきれてないのですが、公式によるとそういうことらしいです笑
https://storybook.js.org/docs/writing-tests/test-addon#comparison-to-the-test-runner

既存のテストファイルとStorybookを、1コマンドで同時にテスト可能に

これまではtest Runnerで自動テストを行う際は、既存のテストは別で実行する必要がありました。
加えて前述の通り、Storybookブラウザを立ち上げる必要があり、ぶっちゃけCIには到底組み込めるようなものではありませんでしたが、
今回のアップデートでVitest環境でテスト可能になったことにより、既存のテストファイル(ex,*.test.tsx)と一緒に、Story上で書かれたテストを実行することが可能になりました。
このため、CIにStorybookテストを組み込むことが可能となり、よりStorybookでのテストの需要が高まったと言えるでしょう🤩

Storybookブラウザ上で、ワンクリックでテスト可能に

Storybookブラウザ上からも簡単にテストを実行し、確認することが可能となりました。
これによって、実際の画面でテストを行えるので、テストの確認がよりしやすくなっております😆

公式のリリースノートの動画がわかりやすいと思うので載せておきます。
https://storybook.js.org/blog/storybook-8-4/

早速取り入れてみた

新しいものが出たら試してみようの精神で、早速ITトレンドにも取り入れてみました!
簡単にその時の工程を記載します。

バージョンアップ前環境

  • Next.JSバージョン14.2.4
  • Reactバージョン18.2.0
  • TypeScriptバージョン5.5.2
  • StoryBookバージョン8.1.10
  • Vitestバージョン1.2.0
  • MSWバージョン2.2.14

導入の前提条件

公式を見ると、導入の際の前提条件が記載されてます。

  • Storybook8.5以上
  • Vitest2.1以上
    まだVitestを使用していない場合は、アドオンをインストールするときにインストールされ、設定されます
  • (オプション) MSW2.0以上
    MSWがインストールされている場合は、Vitestの依存関係と競合しないように、v2.0.0以降である必要があります。
  • Next.js14.1以上且つ@storybook/experimental-nextjs-viteフレームワークを使用していること

とのことなので、Storybookと関連アドオンを、用意されたコマンドでアップデートします。

npx storybook@next upgrade

Vitestとその関連ファイルも一緒にアップデートします。

そして、@storybook/experimental-nextjs-viteアドオンをインストールし、main.tsに追記します。

npm install --save-dev @storybook/experimental-nextjs-vite
./storybook/main.ts
- import { StorybookConfig } from '@storybook/nextjs';
+ import { StorybookConfig } from '@storybook/experimental-nextjs-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],

  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-mdx-gfm',
    '@storybook/addon-styling',
    '@storybook/addon-a11y',
  ],
- framework: '@storybook/nextjs',
+ framework: '@storybook/experimental-nextjs-vite',

  staticDirs: ['../public'],

  docs: {},
};

export default config;

これで前提条件は満たせました。

いざ導入

以下のコマンドを実行して、Vitestを使用してStoryをテストとして実行するプラグインを含むアドオンをインストールおよび設定します。

npx storybook add @storybook/experimental-addon-test

このコマンド叩くと、必要な設定ファイルを色々勝手に作成してくれますが、少しだけ自分の環境に合わせて手を加える必要がありました。
公式は正義なので、公式に従いつつ修正していきます。

新規で、Storybook上でVitestをセットアップするためのファイルを作成します。

.storybook/vitest.setup.ts
.storybook/vitest.setup.ts
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { beforeAll } from 'vitest';
import { setProjectAnnotations } from '@storybook/experimental-nextjs-vite';
import * as previewAnnotations from './preview';

const annotations = setProjectAnnotations([a11yAddonAnnotations, previewAnnotations]);

// Run Storybook's beforeAll hook
beforeAll(annotations.beforeAll);

既存のVitest用のconfigとは別で設定させたかったので、専用のvitest.workspace.tsを作成します。もちろんplaywrightで動くように設定しました。

vitest.workspace.ts
vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin';
import { storybookNextJsPlugin } from '@storybook/experimental-nextjs-vite/vite-plugin';

export default defineWorkspace([
  'vitest.config.mts',
  {
    plugins: [
      storybookTest({
        tags: {
          exclude: ['skip'], // skip とタグ付けされたストーリーの自動実行を除外する
        },
        testRunnerOptions: {
          startTimeout: 10000,
          shutdownTimeout: 5000,
        },
      }),
      storybookNextJsPlugin(),
    ],
    optimizeDeps: {
      include: [
        'react',
        'react-dom',
        '@storybook/addon-actions',
        'react-icons/fa',
        'react-icons/ai',
        'react-icons/bs',
        'react-icons/gi',
        'react-icons/hi2',
        'react-content-loader',
        '@fortawesome/pro-solid-svg-icons',
        '@fortawesome/react-fontawesome',
        'next/link',
        'axios',
      ],
    },
    test: {
      name: 'storybook',
      exclude: ['node_modules/**'],
      isolate: false,
      setupFiles: ['./.storybook/vitest.setup.ts'],
      // environment: 'jsdom', // jsdom上でテストしたい場合はコメントアウトを外す
      browser: {
        enabled: true,
        name: 'chromium',
        provider: 'playwright',
        headless: true,
      },
    },
  },
]);

最後に、プロジェクトがDockerコンテナ上にあるため、docker起動時にplaywrightをインストールしてあげる記述をDockerfileに記載します。これがないとdocker compose up時に毎回playwrightを手動でインストールしないといけなくなって面倒だし、CIにもあげられないので💦

Dockerfile
# システムの依存関係をインストール
RUN apt-get update && apt-get install -y \
    chromium \
    libnss3 \
    libfreetype6 \
    libharfbuzz0b \
    ca-certificates \
    fonts-freefont-ttf \
    libgbm1 \
    libxshmfence1 \
    libasound2 \
    libatk1.0-0 \
    libatk-bridge2.0-0 \
    libcairo2 \
    libcups2 \
    libdbus-1-3 \
    libexpat1 \
    libfontconfig1 \
    libgcc1 \
    libglib2.0-0 \
    libnspr4 \
    libpango-1.0-0 \
    libx11-6 \
    libx11-xcb1 \
    libxcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxi6 \
    libxrandr2 \
    libxrender1 \
    libxss1 \
    libxtst6 \
    xvfb \
    dbus \
    dbus-x11 \
    && rm -rf /var/lib/apt/lists/*

# Playwrightの環境変数を設定
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/lib/playwright
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser

# 依存関係のインストールとPlaywrightのセットアップ
RUN mkdir -p /usr/lib/playwright && \
    npx playwright@1.50.0 install chromium && \
    npx playwright@1.50.0 install-deps chromium

# vitestの設定
ENV VITEST_BROWSER_EXECUTABLE_PATH=/usr/lib/playwright/chromium-*/chrome-linux/chrome

これで導入とセットアップが完了したかなと思います。

Storybookでテストコードを書く

セットアップは完了したので、Story上でテストコードも書いておきます。
資料請求ボタンコンポーネントは、アプリの売上に直結する重要コンポーネントなので、このボタンが正常に動作するかのテストコードを書いておきます。

テスト元コンポーネント
ProductCardCartButton.tsx
import { FiFolderPlus, FiFolderMinus } from 'react-icons/fi';

import { useCart } from '@/hooks/cart/useCart';
import style from '@components/molecules/CartButton/ProductCard/index.module.scss';

const ProductCardCartButton = ({
  product_id,
  position,
}: {
  position: string;
  product_id: number;
}) => {
  const { isProductInCart, handleAddToCart, handleRemoveFromCart } = useCart(product_id, position);

  return (
    <>
      {isProductInCart ? (
        <button
          className={`${style['cart-button']} ${style['cart-delete']}`}
          onClick={handleRemoveFromCart}
        >
          <FiFolderMinus size={30} />
          <span className={style.text}>リストに追加済み</span>
        </button>
      ) : (
        <button
          className={`${style['cart-button']} ${style['cart-add']}`}
          onClick={handleAddToCart}
        >
          <FiFolderPlus size={30} />
          <span className={style.text}>リストに追加する</span>
        </button>
      )}
    </>
  );
};

export default ProductCardCartButton;

具体的なカート処理自体はカスタムフックで共通化しており、その中でロジックを書いています。
なので事前に、カート通信APIモックと、その通信で使うリクエストとレスポンスのモックデータを作成しておく必要があります。
これはMSWを用いることで、とても直感的に簡単に実装が可能です。
https://mswjs.io/

既に一部のテストコードでMSWを使っていたので、今回もそれを使って書きます。
そして、Storybookはmsw-storybook-addonを用いることで、Story上でもMSWも使えるようになります。
設定も、以下のようにするだけで、mswで作成したAPIモックを見るようになります。

.storybook/preview.ts
import { Preview } from '@storybook/react';
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
import '../src/styles/pages/global.scss';
+ import { initialize, mswLoader } from 'msw-storybook-addon';

+ initialize();

const preview: Preview = {
+   loaders: [mswLoader],
// その他設定コード
}

事前に作成したモックAPIハンドラーと、カート処理で使ってるContextをモックしてDecolatorとしたモジュールをimportしておき、play関数を用いてテストコードを書きます。

テストコード付きStoryファイル
ProductCardCartButton.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within, expect } from '@storybook/test';
<!-- mswでインターセプトする -->
import { CartHandlers } from '@/mocks/handlers/cart';
import { CartContextDecorator } from '@/mocks/storybook/CartContextDecorator';

import ProductCardCartButton from '.';

const meta: Meta<typeof ProductCardCartButton> = {
  component: ProductCardCartButton,
  parameters: {
    msw: {
      handlers: CartHandlers,
    },
  },
  title: 'Buttons/CartButton/ProductCardCartButton(製品カードリスト追加ボタンPC)',
};

export default meta;

type Story = StoryObj<typeof ProductCardCartButton>;

// カート未追加の状態
export const NotInCart: Story = {
  args: {
    product_id: 2,
    position: 'product_card',
  },
  decorators: [CartContextDecorator([])],
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 初期状態の確認
    const addButton = canvas.getByRole('button', { name: /リストに追加/ });
    await expect(addButton).toBeInTheDocument();

    // ボタンクリック
    await userEvent.click(addButton);

    // 状態変化の確認
    await waitFor(async () => {
      const removeButton = canvas.getByRole('button', { name: /リストに追加済み/ });
      await expect(removeButton).toBeInTheDocument();
    });
  },
};

// カート追加済みの状態
export const InCart: Story = {
  args: {
    product_id: 2,
    position: 'product_card',
  },
  decorators: [CartContextDecorator([2])],
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 初期状態の確認
    const removeButton = canvas.getByRole('button', { name: /リストに追加済み/ });
    await expect(removeButton).toBeInTheDocument();

    // ボタンクリック
    await userEvent.click(removeButton);

    // 状態変化の確認
    await waitFor(async () => {
      const addButton = canvas.getByRole('button', { name: /リストに追加/ });
      await expect(addButton).toBeInTheDocument();
    });
  },
};

これで、カートから追加、カートから削除時のコンポーネントテストが出来上がりました!

storybook-test

では動かしてみる

Storybookブラウザ

セットアップは完了したので、実際にnpm run storybookして立ち上げてみます。

まずそもそもの起動が鬼速い!
Webpack時代は最初の起動に20秒以上かかる時もありましたが、viteだと10秒もかからずに起動します!
terminal

そしてStorybookブラウザには、左下にテストが行えるエリアが出現!
storybook

テストボタンを押してみると、かっこいい青緑グラデーションがくるくるしだして、テスト完了したコンポーネントにチェックマークがつきました!
storybook

Storybookに上がっているコンポーネントは全てテストしてくれてます!
play関数を使ったテストコードを書いてないStoryも、エラーなくちゃんと動いてるかどうかをテストしてくれるんですね!
storybook

目玉のボタンを押すと、watchモードにすることもできます。
試しにその状態でわざとテストを落ちるようにコードを書き換えてみると、
コード変更した瞬間に自動でテストが再実行され、エラーコンポーネントが赤くなって表示されました!
どこで落ちているのかが視覚的にとてもわかりやすい!
storybook

CLI

次にCLIで、テストコマンドを叩いた時の挙動をみてみます。
Storybookでのテストなのか、テストファイルのテストなのかをわかりやすくするために、Storybookテストには[Storybook]、テストファイルのテストには結合テストの意味の[integration]というタグをセットしてあります。
npm run testでvitestテストを回してみた結果がこちら↓
cli

両方のテストが実行されていることがわかります!✨
現状vitestのテストはCIに組み込んでいるので、これによってStorybook上でのテストもCIにも組み込むことができ、より広範囲のテストを自動化することが可能になりました!

CI

まとめ

Storybookは1ヶ月単位でバージョンアップがリリースされるほど進化のスピード感が早く、驚くような機能が増えていってます。今後どのようなリリースが待っているかが楽しみですね😊

今回紹介したStorybookによるテスト手法は、初心者にとっても視覚的にわかりやすいため、従来よりもテストのハードルを下げてくれるんじゃないかという期待もしております。

実は今回ITトレンドでは、今回のテストを実際にCIに入れて運用はしています。
が、まだまだチーム全体に、「コンポーネントを作成したらStorybookを作り、テストを書こう」という習慣と文化が完全に根付いているわけではないので、
この魅力を発信し、少し文化形成のきっかけになればいいなという想いもあったりしました笑笑
今後はテスト文化の形成を頑張っていきたいところですね。

今回の記事で興味を持たれた方は是非、Storybookとvitestを取り入れてみてください!

最後まで読んでいただきありがとうございました!

株式会社イノベーション Tech Blog

Discussion