🎉

StorybookでIntegration Testを書くことの良し悪し

に公開

StorybookでIntegration Testを書くことの良し悪し

この記事は LayerX Tech Advent Calendar 2025 21 日目の記事です。


勤怠チームのあない(@pirosikick)です。
今年の 8 月に チームにジョインしてから Web フロントエンドのタスクをこなすときはなるべく Storybook の Play function を用いて Integration Test を追加しながらタスクを進めています。
結果として Storybook が充実しつつあり、チームメンバーから感謝されることもありました。

同僚の@ta1m1kamが褒めてくれたシーン

前職以前では Vitest(or Jest)+ testing-library という構成で Integration Test を書くことが多かったので、本記事では Storybook で Integration Test を書くことの良し悪しについてまとめます。

このブログにおける Integration Test の定義

まず、この記事での「Integration Test」の定義ですが、Testing Trophy の定義に沿って、

Verify that several units work together in harmony.
(複数のユニットが協調して動作するかを検証する)

ものを Integration Test と呼びます。具体的には、

  • ページやモーダルなど、複数のコンポーネントを組み合わせたコンポーネントに対して、
  • API などの外部システムはモックした上で、
  • ユーザー操作に近い形で振る舞いを検証するテスト

を指しています。

Integration Test の良いところは、複数のユニットを組み合わせた振る舞いを検証することで、個々のユニットの内部実装をリファクタリングしやすくなる点です。
テストが振る舞いを保証してくれるため、安心してリファクタリングを進められ、ソフトウェアの開発持続性を保ちやすくなります。

Storybook で Integration Test を書く構成(勤怠チームの場合)

同僚の @ta1m1kamが「Storybook Play function で AHA testing のすゝめ」という記事を書いていますので、詳しくはそちらを参照してください。

https://tech.layerx.co.jp/entry/2025/04/21/114307

上記の記事から抜粋すると、以下のような感じで書いてます。

以下、簡単なサンプルコードです。

import { HttpResponse } from "msw";
// typescript-mock-dataで生成したモックデータ
import { aSomeGraphQLQuery } from "../gql/__generated__/mock";
// @graphql-codegen/typescript-msw で生成した msw の handler
import { mockSomeGraphQLQuery } from "../gql/__generated__/msw";

// テスト対象のコンポーネント
import { SomeComponent } from "./SomeComponent";

const meta = {
  title: "SomeComponent",
  component: SomeComponent,
  decorators: [(Story) => /* 割愛 */ <Story />],
} satisfies Meta<typeof SomeComponent>;
export default meta;

export const SomeStory: Story = {
  parameters: {
    msw: [
      // GraphQLのqueryをモック
      mockSomeGraphQLQuery(() =>
        HttpResponse.json({
          data: aSomeGraphQLQuery({
            /* 割愛 */
          }),
        })
      ),
    ],
  },
  play: async ({ canvas }) => {
    // ここにテストを書く
  },
};

この構成でハマった事象を 1 点だけ紹介すると、graphql-codegen-typescript-mock-data が出力するモックデータの factory 関数は、GraphQL のデータモデルが循環参照していると「RangeError: Maximum call stack size exceeded」を吐いて落ちるので注意です。

// ModelAとModelBが循環参照になっている
type ModelA {
  …
  modelB: ModelB
  …
}

type ModelB {
  …
  modelA: ModelA
  …
}

そういう場合は、以下のように手動でモックデータの factory 関数を書いて対応しています。

import { ModelA, ModelB } from "../gql/__generated__/graphql";
import {
  // graphql-codegen-typescript-mock-dataが生成したfactory関数
  aModelA as originalAModelA,
  aModelB as originalAModelB,
} from "../gql/__generated__/mock";

export const aModelA = (overrides?: Partial<ModelA>): ModelA =>
  originalAModelA({
    ...overrides,

    // 循環参照してしまうフィールドは自動生成しないようにする
    modelB: overrides?.modelB || null,
    // デフォルトでは以下のように自動生成されている
    // modelB: overrides?.modelB || originalAModelB(),
  });

export const aModelB = (overrides?: Partial<ModelB>): ModelB =>
  originalAModelB({
    /* 割愛 */
  });

Storybook Play function の良い点

Vitest + testing-library 構成と比較して、Storybook Play function が良い点は以下です。

  • ブラウザで実行・確認できる
  • AAA パターンで自然に整理される

ブラウザで実行・確認できる

使ったことがない人でも容易に想像できると思うのですが、Storybook Play function では、ブラウザ上でテストの実行結果が確認できるので、デバッグが容易です。

Vitest + testing-library でテストを書く場合、 失敗時のデバッグはコンソールに出力された HTML を眺めて「どこがおかしいのか」を探す必要があります。また、テストが成功しても、画面を通して表示確認・動作確認をやらないと結局安心できないので、Storybook + テスト機能は理にかなっているなと感じています。

AAA パターンで自然に整理される

AAA(Arrange-Act-Assert)パターンとは、テストコードを「準備」「実行」「検証」の 3 つに分けて整理する考え方です。
テストコード上で、何を準備して、何を実行して、何を確認しているのかが追いやすくなります。
Storybook は、この「準備」と「実行」の多くを Story 側の API で表現できます。

「準備」に相当するものとしては、次のような API があります。

  • decorators
  • args
  • parameters.msw
    • msw-storybook-addon を使っている場合、msw の handler を設定できる

これらを使って、コンポーネントの状態や依存関係を Story 単位で宣言的に書けます。

また、コンポーネントの描画自体は Story ごとに自動で行われるため、テスト側で「描画する」という「実行」を明示的に書く必要はありません。

その結果、Play function に残るのは、

  • ユーザー操作などの一部の「実行」
  • 表示結果を確認する「検証」

だけになります。 Storybook の API を前提にすると、テスト全体が自然に整理され、Play function も責務が小さくなります。結果として、書きやすく、後から読んでも意図を追いやすいテストになります。

人間にとっても読みやすいですし、コーディングエージェントがテストコードを書く場合に特別な指示をしなくても AAA パターンに収まりやすくなるのは素晴らしい点だなと思います。

Storybook Play function の良くない点

逆に、Storybook Play function のデメリットを挙げるとすると、以下の 2 点です。

GUI を立ち上げるのが面倒

先ほど「理にかなっている」とか言っておきながらですが、Storybook の起動は時間がかかるので、やはり面倒です。local でテストが通ることを確認せずに push し、CI で怒られることも多々あります。
バクラク勤怠を localhost で立ち上げて、データを準備して確認するよりは絶対楽なはずなので、今後 Storybook をより充実していけば自然と立ち上げるようになるのではと思っています。

また、先日同僚が@storybook/nextjs-viteを導入してくれて起動時間をかなり短縮できました。

テスト実行に時間がかかる

テストの実行時間に関しては、Vitest + testing-library の方が構成がシンプルな分、速い気がしています(計測・比較まではやってないので、個人の体感になりますが)。
Integration Test は、DOM ツリーを構築し実際に探索・操作して検証するため、どのツールを使うかに関わらず、実行時間は長くなりがちです。
個人的には Integration Test を Web フロントエンドでリファクタリングを駆動するための「開発ツール」と捉えているので、ガンガン書いていきたい気持ちはありますが、実行時間とのバランスは常に悩ましいポイントです。

勤怠チームでは、現在@storybook/test-runnerでテスト実行していますが、@storybook/addon-vitestに切り替えると速くなりそうなので、今後検証・導入したいです。

おわりに

改めて振り返ると、Integration Test は、リファクタリングを安全に進めるための土台として、ソフトウェアの開発持続性を支えてくれる存在だと感じています。
振る舞いがテストで保証されているからこそ、内部実装を気兼ねなく触れますし、結果として開発スピードも落とさずに済みます。

一方で、以下のような課題もあります。

  • 前述の通り、Integration Test は実行時間がかかりやすく、
    テストケースの追加には戦略やガイドラインが必要そう
  • モーダルやスライダー、独自実装の選択 UI など、
    複雑な UI を持つページについては、まだ十分に Integration Test を書けていない

Integration Test を「リファクタリングを駆動するための開発ツール」として活かし続けるためにも、テストの守備範囲や粒度を意識しながら、無理なく追加していくことが重要だと感じています。

今後は Integration Test 環境の整備を進めつつ、バクラク勤怠だけでなくバクラクシリーズの開発スピードを維持できる土台を作っていきたいです。
「テストは福利厚生!」というスタンスを忘れず、これからも少しずつ育てていこうと思います。

LayerX

Discussion