🙌

[Next.js]フロントテストのコストはStorybookで削減出来る

2023/02/14に公開

1.フロントテストと Storybook の活用

フロントテストのどこにコストがかかるのか

バックエンドのテストはシンプルな入力と出力が多いので、テスト作成は比較的簡単です。一方、フロントエンドのテストは UI イベントや動的な要素も含まれ、複数の出力があるため、テスト作成はより複雑になります。これにより、テスト作成のコストが増大します。

生 jest で書くフロントテストと心の目

Jest は主に JavaScript のユニットテストをサポートするツールであり、UI レンダリングのテストを行うためには対応するライブラリが必要です。Jest は DOM イベントをエミュレートすることはできますが、ユーザーの操作と同様のインタラクションを再現することは困難です。機能の多いコンポーネントをテストする際に、Jest を使用するとテストコードが複雑になりがちです。そして最大の問題は、視覚的な部分をテストしているにも関わらず、テスト作成時に状態を視認することが出来ません。開発者は心の目を持つことが要求されます。これがフロントエンドテストを難しくする一因です。

Storybook によるコンポーネントサンプリングの有用性

Storybook は、UI コンポーネントのライブラリを作成するためのツールです。作成した UI をブラウザで表示することができるため、視覚的な状態を確認することができます。UI コンポーネントごとに独立させてサンプルを作成すれば、個別にコンポーネントが正常に表示され、スタイルが正常に適用されているかを確認することができます。また、ツリー形式でコンポーネントの一覧を確認することができるので、既存のコンポーネントを管理しやすくなります。これにより、開発のハンドオーバーなどが容易になります。

Storybook の導入と維持コスト

Storybook の導入には、開発に使用するフレームワークに対応するプラグインや設定が必要なため、初期の課題となっています。また、コンポーネント作成毎にサンプリングするための追加コード作成が必要となり、開発の負担となります。

コストがかかるから figma とコンポーネントを揃えておけば解決?

Figma はデザインツールであり、コードとは独立しています。そのため、実際のコードとデザインが一致しているか確認することが困難です。Figma だけを使っていると、UI イベントに対するレスポンスなどが分かりません。また、検証したいコンポーネントがシステムのどこにあるか確認して動作を確認する必要があり、それだけでは検証できないときは確認用のコードを別に書く必要があります。これは再利用するコンポーネントにとって非常に無駄なコストとなります。

Storybook 維持のために毎回用意しなければならないファイル

Storybook でコンポーネントのサンプリングを行う場合、それぞれのコンポーネントに対応するファイルを以下のような形で都度用意する必要があります。この作業は新しいコンポーネントを追加するたびに行う必要があるため、毎回の追加に対して負担となります。このため、大きなコンポーネントを作成する傾向があり、機能単位で分離することが難しくなってしまいます。コピペを使っても作業は楽しいものではありません。

import { expect } from "@storybook/jest";
import { within } from "@storybook/testing-library";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { Test } from "./Test";

const meta: ComponentMeta<typeof Test> = {
  title: "Components/Samples/Test",
  component: Test,
  parameters: {
    //  nextRouter: { asPath: '/' },
  },
  args: {},
};
export default meta;

export const Primary: ComponentStoryObj<typeof Test> = {
  args: {},
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    expect(canvas.getByText("Test")).toBeInTheDocument();
  },
};

ファイル作成の負担は scaffold で解決

Scaffold の導入は、新規コンポーネント作成に対する負担を減らすために効果的です。Storybook のサンプリング以外にも、繰り返し使いそうな部分をテンプレート化し、コマンドで自動生成することで、時間の無駄を削減し、開発効率の向上が期待できます。さらに、手作業が減ることで人間のバラツキを防ぎ、コードの一貫性も保証しやすくなります。

サンプリングしたコンポーネントの動作をテストする

Storybook を使っても、コンポーネントの見た目だけの確認にとどまります。これを解消するために、addon として@storybook/addon-interactions を追加することで、コンポーネントの動作に関してもテストすることができます。このテストは jest と似た形式で書けますが、最大の違いはブラウザ上で動作中の内容を確認できる点です。また、コマンドラインからも呼び出すことが可能なため、CI/CD ワークフローにも対応しています。

2.Storybook によるテスト環境の構築(Next.js 用)

Next.js 用パッケージのインストール

まずは、Next.js の動作に必要なものをインストールします。Sass の場合は使わない場合は除外することができます。

yarn add next react react-dom
yarn add -D typescript @types/react @types/node sass

Storybook6 でテストを書くために必要なパッケージ

Storybook の運用に必要なものをインストールします。http-server と npm-run-all は、コマンドラインからのテスト時に使用するものです。また、@node-libraries/scaffold は、コンポーネントの自動生成に利用するものです。

 yarn add -D @storybook/react @storybook/builder-webpack5 @storybook/manager-webpack5 @storybook/addon-essentials @storybook/addon-interactions @storybook/jest @storybook/testing-library @storybook/addon-coverage @storybook/test-runner storybook-addon-next postcss http-server npm-run-all @node-libraries/scaffold

Storybook の設定

.storybook/main.js

addon の基本セットとして、@storybook/addon-essentials をインストールします。ターゲットが Next.js の場合は、storybook-addon-next も必要です。このアドオンを入れることで、Next.js に関する設定を Storybook に簡単に入れることができます。また、テストを書くには@storybook/addon-interactions が必要です。カバレッジレポートを出すためには@storybook/addon-coverage も必要です。カバレッジレポートから除外するためのオプションも設定されています。Storybook 7 より前のドキュメントでは、オプションの istanbul が instanbul と間違えられていることがありますので、注意してください。7 以降では修正 PR をマージしてもらったので大丈夫ですが、それ以前のバージョンには修正が反映されていない場合があります。

module.exports = {
  core: {
    builder: "webpack5",
  },
  stories: ["../src/**/*.stories.@(tsx)"],
  addons: [
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
    {
      name: "@storybook/addon-coverage",
      options: {
        istanbul: {
          exclude: ["**/components/**/index.ts"],
        },
      },
    },
    "storybook-addon-next",
  ],
  features: {
    storyStoreV7: true,
    interactionsDebugger: true,
  },
  typescript: { reactDocgen: "react-docgen" },
};

package.json

yarn test コマンドを使用することで、Storybook のビルドからテストの実行まで一連の流れが行えます。CI/CD 環境ではこのコマンドを利用することができます。単純に Storybook を起動するだけなら yarn storybook コマンドを使用し、coverage の確認には yarn storybook:test コマンドを実行します。

{
  "scripts": {
    "test": "yarn storybook:build && npm-run-all -p -r storybook:start storybook:test",
    "storybook": "start-storybook -p 9001",
    "storybook:build": "build-storybook",
    "storybook:start": "http-server -s -p 9001 storybook-static",
    "storybook:test": "test-storybook --url http://localhost:9001 --coverage"
  },
  "dependencies": {
    "next": "^13.1.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@node-libraries/scaffold": "^0.0.3",
    "@storybook/addon-coverage": "^0.0.8",
    "@storybook/addon-essentials": "^6.5.16",
    "@storybook/addon-interactions": "^6.5.16",
    "@storybook/builder-webpack5": "^6.5.16",
    "@storybook/jest": "^0.0.10",
    "@storybook/manager-webpack5": "^6.5.16",
    "@storybook/react": "^6.5.16",
    "@storybook/test-runner": "^0.9.4",
    "@storybook/testing-library": "^0.0.13",
    "@types/node": "^18.13.0",
    "@types/react": "^18.0.28",
    "http-server": "^14.1.1",
    "npm-run-all": "^4.1.5",
    "postcss": "^8.4.21",
    "sass": "^1.58.0",
    "storybook-addon-next": "^1.7.1",
    "typescript": "^4.9.5"
  },
  "license": "MIT"
}

tsconfig.json

基本的には Next.js が自動生成するところはそのままにしますが、baseUrl を追記する必要があります。これがないと Storybook 起動時に addon が正常に動作しません。

{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "incremental": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "baseUrl": "."
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

コンポーネントの作成

以下のコマンドを実行することで、必要なファイルを作成して、テンプレートからコンポーネントを作成することができます。テンプレートはhttps://github.com/node-libraries/scaffold/tree/master/templates/storybook6から取得できますが、ローカル上でテンプレートを作成しても構いません。

yarn scaffold create -t https://github.com/node-libraries/scaffold/tree/master/templates/storybook6 Samples/Test

コマンドによって以下のようなファイルが作成されます。修正無しで Storybook 上に表示するところまで生成されます。

src/components/Samples/Test/index.ts

export * from "./Test";

src/components/Samples/Test/Test.module.scss

.root {
}

src/components/Samples/Test/Test.stories.tsx

ComponentMetaはコンポーネントの基本情報を設定するものです。Storybook 7 では型名がMetaになります。ComponentStoryObjにはサンプルとして表示する Story の内容を記述する形になっており、ComponentMetaで設定した情報は上書きすることができます。Storybook 7 では型名がStoryObjになります。

titleは Storybook のツリー上に表示されるときの名前、componentはサンプルとして表示するコンポーネント、parametersはアドオンやデコレータに値を渡すために使います。nextRouterはコメントアウトされていますが、パスの指定などができます。argsはコンポーネントに引数がある場合に利用します。

playはインタラクションテストを記述するものです。基本的には jest と同じような書き方をします。

import { expect } from "@storybook/jest";
import { within } from "@storybook/testing-library";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { Test } from "./Test";

const meta: ComponentMeta<typeof Test> = {
  title: "Components/Samples/Test",
  component: Test,
  parameters: {
    //  nextRouter: { asPath: '/' },
  },
  args: {},
};
export default meta;

export const Primary: ComponentStoryObj<typeof Test> = {
  args: {},
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    expect(canvas.getByText("Test")).toBeInTheDocument();
  },
};

src/components/Samples/Test/Test.tsx

import React, { FC } from "react";
import styled from "./Test.module.scss";

interface Props {}

/**
 * Test
 *
 * @param {Props} { }
 */
export const Test: FC<Props> = ({}) => {
  return <div className={styled.root}>Test</div>;
};

表示内容

Storybook 上では次のように表示され、テスト結果が出力されます。

ボタンでクリックイベントをモックする

ボタンコンポーネントを作ります。

yarn scaffold create -t https://github.com/node-libraries/scaffold/tree/master/templates/storybook6 Samples/Button

src/components/Samples/Button/Button.tsx

ボタンの引数で okClick を受け取るようにします。

import React, { FC } from "react";
import styled from "./Button.module.scss";

interface Props {
  onClick: () => void;
}

/**
 * Button
 *
 * @param {Props} { }
 */
export const Button: FC<Props> = ({ onClick }) => {
  return (
    <button className={styled.root} onClick={onClick}>
      Button
    </button>
  );
};

src/components/Samples/Button/Button.stories.tsx

argsonClick: jest.fn() を設定することで、モック関数として扱われる onClick を受け取ることができます。これにより、コンポーネントのクリックイベントが実行された際に、onClick が呼び出されたことを確認するテストが作成できます。

import { expect, jest } from "@storybook/jest";
import { userEvent, within } from "@storybook/testing-library";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: ComponentMeta<typeof Button> = {
  title: "Components/Samples/Button",
  component: Button,
  parameters: {
    //  nextRouter: { asPath: '/' },
  },
  args: {},
};
export default meta;

export const Primary: ComponentStoryObj<typeof Button> = {
  args: { onClick: jest.fn() },
  play: async ({ canvasElement, args: { onClick } }) => {
    const canvas = within(canvasElement);
    userEvent.click(canvas.getByRole("button", { name: "Button" }));
    expect(onClick).toBeCalled();
  },
};

ログインフォームのテストを行う

src/components/Samples/Login/Login.module.scss

.root {
  .form {
    width: 300px;
    display: grid;
    gap: 8px;
  }
  .input {
    display: flex;
    :first-child {
      width: 120px;
    }
  }
  .error {
    color: red;
    font-size: 0.5em;
  }
}

src/components/Samples/Login/Login.tsx

ログイン用フォームにて、入力値のバリデーションが行われ、認証が成功した場合は、/main へのルーティングが行われます。

import React, { DOMAttributes, FC, useState } from "react";
import styled from "./Login.module.scss";
import { useRouter } from "next/router";

interface Props {}

/**
 * Login
 *
 * @param {Props} { }
 */
export const Login: FC<Props> = ({}) => {
  const router = useRouter();
  const [userError, setUserError] = useState<string>();
  const [passwordError, setPasswordError] = useState<string>();
  const [loginError, setLoginError] = useState<string>();
  const handleSubmit: DOMAttributes<HTMLFormElement>["onSubmit"] = (e) => {
    const user = e.currentTarget.user.value;
    const password = e.currentTarget.password.value;
    if ([user, password].includes("")) {
      user === "" && setUserError("ユーザ名を入力してください");
      password === "" && setPasswordError("パスワードを入力してください");
    } else {
      if (user === "user" && password === "password") {
        router.push("/main");
      } else {
        setLoginError("認証に失敗しました");
      }
    }
    e.preventDefault();
  };
  return (
    <div className={styled.root}>
      <form onSubmit={handleSubmit} className={styled.form}>
        <div className={styled.input}>
          <label htmlFor="user" placeholder="ユーザ名">
            ユーザ名
          </label>
          <input type="text" id="user" />
        </div>
        {userError && (
          <div className={styled.error} role="alert">
            {userError}
          </div>
        )}
        <div className={styled.input}>
          <label htmlFor="password" placeholder="パスワード">
            パスワード
          </label>
          <input id="password" type="password" />
        </div>
        {passwordError && (
          <div className={styled.error} role="alert">
            {passwordError}
          </div>
        )}
        <button type="submit">ログイン</button>
        {loginError && (
          <div className={styled.error} role="alert">
            {loginError}
          </div>
        )}
      </form>
    </div>
  );
};

src/components/Samples/Login/Login.stories.tsx

Primaryは素の表示状態、Errorは入力のバリデーションチェックに引っかかった場合、Failは認証失敗、Passは認証成功のテストを行っています。認証成功時の判定はnextRouterpushをモック化して行っています。

Primaryは元の状態を表示します。Errorは入力値のバリデーションチェックで失敗した場合、Failは認証に失敗した場合、Passは認証に成功した場合のテストを行います。認証に成功した場合の判定は、nextRouterpushメソッドをモック化して行います。

import { expect, jest } from "@storybook/jest";
import { userEvent, within } from "@storybook/testing-library";
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { Login } from "./Login";
import { waitFor } from "@testing-library/dom";

const meta: ComponentMeta<typeof Login> = {
  title: "Components/Samples/Login",
  component: Login,
  parameters: {
    //  nextRouter: { asPath: '/' },
  },
  args: {},
};
export default meta;

export const Primary: ComponentStoryObj<typeof Login> = {};

export const Error: ComponentStoryObj<typeof Login> = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await waitFor(() => {
      userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
    });
    await waitFor(() => {
      expect(
        canvas.getByText("ユーザ名を入力してください")
      ).toBeInTheDocument();
    });
    await waitFor(() => {
      expect(
        canvas.getByText("パスワードを入力してください")
      ).toBeInTheDocument();
    });
  },
};
export const Fail: ComponentStoryObj<typeof Login> = {
  parameters: {
    nextRouter: {
      push: jest.fn(),
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await waitFor(async () => {
      await userEvent.type(canvas.getByLabelText("ユーザ名"), "fail", {
        delay: 10,
      });
    });
    await waitFor(async () => {
      await userEvent.type(canvas.getByLabelText("パスワード"), "password", {
        delay: 10,
      });
    });
    await waitFor(() => {
      userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
    });
    await waitFor(() => {
      expect(canvas.getByText("認証に失敗しました")).toBeInTheDocument();
    });
  },
};
export const Pass: ComponentStoryObj<typeof Login> = {
  parameters: {
    nextRouter: {
      push: jest.fn(),
    },
  },
  play: async ({
    canvasElement,
    parameters: {
      nextRouter: { push },
    },
  }) => {
    const canvas = within(canvasElement);
    await waitFor(async () => {
      await userEvent.type(canvas.getByLabelText("ユーザ名"), "user", {
        delay: 10,
      });
    });
    await waitFor(async () => {
      await userEvent.type(canvas.getByLabelText("パスワード"), "password", {
        delay: 10,
      });
    });
    await waitFor(() => {
      userEvent.click(canvas.getByRole("button", { name: "ログイン" }));
    });
    await waitFor(() => {
      expect(push).lastCalledWith("/main");
    });
  },
};

表示内容

Coverage の確認

テストを実行します。

yarn test

実行結果として、以下のような出力が得られます。これを CI/CD に組み込めば、PR でテストが網羅的に行われているか確認することができます。

3.まとめ

Storybook では、表示状態の確認とテストの実行を同時に行うことで、テストの作成コストを大幅に削減することができます。一方、Jest の場合、テスト作成時に表示状態を目視することができませんが、Storybook では動作状態を視覚的に確認しながらテストを作成することが可能です。これは大きなメリットとなります。また、Storybook のテスト実行環境はブラウザに近いものとなっており、jsdom などを使って環境をエミュレートする場合よりも、実際の環境に近いテストが実施できます。フロントエンドテストの負担に困っているプロジェクトでは、Storybook へのテストの移行を検討することをお勧めします。

  • 今回作ったサンプルソース

https://github.com/SoraKumo001/storybook-test

GitHubで編集を提案

Discussion