[Next.js]フロントテストのコストはStorybookで削減出来る
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
args
に onClick: 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
は認証成功のテストを行っています。認証成功時の判定はnextRouter
のpush
をモック化して行っています。
Primary
は元の状態を表示します。Error
は入力値のバリデーションチェックで失敗した場合、Fail
は認証に失敗した場合、Pass
は認証に成功した場合のテストを行います。認証に成功した場合の判定は、nextRouter
のpush
メソッドをモック化して行います。
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 へのテストの移行を検討することをお勧めします。
- 今回作ったサンプルソース
Discussion