Storybook 駆動開発 @ CSF3.0
Storybook CSF3.0 の概要
単体テスト・結合テスト・Storybook を充実させるためには、多くの工数が必要です。堅牢なフロントエンド開発のためとはいえ、これらのメンテナンスは日に日に負担が増しています。似かよったテストケースでは、同じような下作業をそれぞれに用意する必要がありました。
Component Story Format(CSF)は、この課題への取り組みとして開発されました。「様々なソリューションで再利用可能な資材」 が用意できれば、開発は素早く・より楽しいものになります。リリース間近の CSF3.0 はより一層、そのゴールを明確に示してくれています。
testing-library で Story にインタラクションを
CSF3.0 新機能の中で際立っているものが play 関数 です。@testing-library/user-event
を利用すると、Story 単位でインタラクション登録を行うことができます。以下のコードはテストではありません。CSF3.0 の play 関数であり、Storybook 上でコンポーネントがマウントされると実行されるインタラクションです。
export const Filled = {
...Empty,
play: () => {
userEvent.type(screen.getById("user"), "shilman@example.com");
userEvent.type(screen.getById("password"), "blahblahblah");
},
};
このインタラクションは Storybook で確認するためだけでなく 「テストで再利用可能」 なものになっています。testing-library で記述するテストは、ボタンをクリックしたり・テキストを入力することができますが、これまで「盲目」で取り組み辛いものでした。これからは目視でインタラクションを確認しながら、テストを書く事ができます。
Story を Jest に取り込む
@storybook/testing-react
を利用すると、Story を Jest に取り込むことができます。CSF3.0 と同様に準備中ですが、@storybook/testing-react@next
で今すぐ試す事ができます。まずはテキストボックスの Story に、"Hello world!"
を入力するインタラクションを登録してみましょう。
export const InputFieldFilled: Story<InputFieldProps> = {
play: async () => {
await userEvent.type(screen.getByRole("textbox"), "Hello world!");
},
};
composeStories
関数は、セットアップされた Story をそのまま jest で再利用(render)可能にする合成関数です。render・play・assert するだけで、テストコード が完成してしまいます。
const { InputFieldFilled } = composeStories(stories);
test("renders with play function", async () => {
render(<InputFieldFilled />);
await InputFieldFilled.play();
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toEqual("Hello world!");
});
詳細は本家の資料を参考にしてみてください。
実践 Storybook 駆動開発 @ CSF3.0
これより紹介するサンプルは、筆者が実践した内容です。「ユーザーを新規作成するフォーム」を想定して、Story と test を書きながら実装しました。
サンプルは以下のリポジトリで公開しています。(型定義まわりがまだ整っていないこともあり @ts-expect-error
が利用されている箇所がありますがご了承ください)
【1】molecules/TextboxWithAlert
エラー表示機能付きのテキストボックスです。インタラクションが伴う機能は無いため、play 関数実行はありません。queryByRole("alert")
がポイントです。testing-library でテストを書くと、セマンティックなコンポーネントを書かなければいけないため、a11y 考慮が自然と取り入れられます。
// index.test.tsx
import { composeStories } from "@storybook/testing-react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import * as stories from "./index.stories";
describe("components/molecules/TextboxWithAlert", () => {
// @ts-expect-error
const { Default, Error } = composeStories(stories);
test("errorMessage が表示されない", async () => {
render(<Default />);
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
test("errorMessage が表示される", async () => {
render(<Error />);
const alert = await screen.findByRole("alert");
expect(alert).toBeInTheDocument();
});
});
Story(CSF3.0) 内訳
// index.stories.tsx
import type { ComponentStoryObj } from "@storybook/react";
import { TextboxWithAlert } from "./";
type Story = ComponentStoryObj<typeof TextboxWithAlert>;
export default { component: TextboxWithAlert };
export const Default: Story = {
args: { inputProps: { defaultValue: "" } },
};
export const Error: Story = {
args: { inputProps: { defaultValue: "" }, errorMessage: "エラー" },
};
Component 内訳
import { Alert } from "@/components/atoms/Alert";
import React from "react";
import styles from "./style.module.css";
type Props = {
inputProps: React.ComponentProps<"input">;
errorMessage?: string;
};
export const TextboxWithAlert = ({
inputProps: { type, ...inputArgs },
errorMessage,
}: Props) => {
return (
<div className={styles.module}>
<input type="text" {...inputArgs} />
<Alert message={errorMessage} />
</div>
);
};
【2】organisms/UserCreateForm
新規ユーザーを作成するフォームです。API 通信を実行する前に「フロントバリデーション」を行うまでがこのコンポーネントの責務で、後続のサーバー側バリデーションエラーと区別できている点がポイントです。入力を中断する「play 関数生成関数」で、各 Story を簡素にしています。
// index.stories.tsx
import type { ComponentStoryObj } from "@storybook/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserCreateForm } from "./";
type Story = ComponentStoryObj<typeof UserCreateForm>;
export default { component: UserCreateForm };
const type = (step: 0 | 1 | 2 | 3) => {
if (step === 0) return;
userEvent.type(screen.getByPlaceholderText("姓"), "田中");
if (step === 1) return;
userEvent.type(screen.getByPlaceholderText("名"), "太郎");
if (step === 2) return;
userEvent.type(
screen.getByPlaceholderText("メールアドレス"),
"example@gmail.com"
);
};
const playFactory = (step: 0 | 1 | 2 | 3) => async () => {
type(step);
userEvent.click(screen.getByRole("button"));
};
export const Invalid1: Story = {
storyName: "未入力で送信",
play: playFactory(0),
};
export const Invalid2: Story = {
storyName: "姓未入力で送信",
play: playFactory(1),
};
export const Invalid3: Story = {
storyName: "名未入力で送信",
play: playFactory(2),
};
export const Valid: Story = {
storyName: "正常入力で送信",
args: { handleSubmit: (data) => {} },
play: playFactory(3),
};
test 内訳
// index.test.tsx
import { composeStories } from "@storybook/testing-react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import * as stories from "./index.stories";
describe("components/organisms/UserCreateForm", () => {
// @ts-expect-error
const { Invalid1, Invalid2, Invalid3, Valid } = composeStories(stories);
test("姓未入力の場合エラーが表示される", async () => {
render(<Invalid1 />);
await Invalid1.play();
const alerts = await screen.findAllByRole("alert");
expect(alerts[0]).toHaveTextContent("姓が未入力です");
});
test("名未入力の場合エラーが表示される", async () => {
render(<Invalid2 />);
await Invalid2.play();
const alerts = await screen.findAllByRole("alert");
expect(alerts[0]).toHaveTextContent("名が未入力です");
});
test("メールアドレス未入力の場合エラーが表示される", async () => {
render(<Invalid3 />);
await Invalid3.play();
const alerts = await screen.findAllByRole("alert");
expect(alerts[0]).toHaveTextContent("メールアドレス");
});
test("期待値を満たしている場合エラーが表示されない", async () => {
render(<Valid />);
await Valid.play();
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
});
Component 内訳
import { TextboxWithAlert } from "@/components/molecules/TextboxWithAlert";
import { yupResolver } from "@hookform/resolvers/yup";
import React from "react";
import { useForm } from "react-hook-form";
import { validationSchema } from "./resolver";
import styles from "./style.module.css";
export type Fields = {
lastName: string;
firstName: string;
mail: string;
};
type Props = {
errors?: Partial<Fields>;
handleSubmit: (data: Fields) => void;
};
export const UserCreateForm = (props: Props) => {
const {
handleSubmit,
register,
formState: { errors },
} = useForm({
resolver: yupResolver(validationSchema),
});
return (
<form onSubmit={handleSubmit(props.handleSubmit)} className={styles.module}>
<TextboxWithAlert
inputProps={{ ...register("lastName"), placeholder: "姓" }}
errorMessage={errors.lastName?.message || props.errors?.lastName}
/>
<TextboxWithAlert
inputProps={{ ...register("firstName"), placeholder: "名" }}
errorMessage={errors.firstName?.message || props.errors?.firstName}
/>
<TextboxWithAlert
inputProps={{ ...register("mail"), placeholder: "メールアドレス" }}
errorMessage={errors.mail?.message || props.errors?.mail}
/>
<div>
<button>submit</button>
</div>
</form>
);
};
import * as yup from "yup";
export const validationSchema = yup
.object({
lastName: yup.string().required("姓が未入力です"),
firstName: yup.string().required("名が未入力です"),
mail: yup.string().required("メールアドレスが未入力です"),
})
.required();
【3】templates/UserCreate
フロントバリデーションを通過した submit が、実際にリクエストを送るコンポーネントです。どういったレスポンスが返りうるのか意識がはたらくため、異常系の考慮漏れに気づきやすくなります。
MSW(Mock Service Worker)でリクエストをインターセプトしており、プログラマブルにモックを返却しています。実際のアプリケーションと同じようなバリデーションロジックも実装できてしまいますが、あまり意味がないため簡素なものにとどめておきます。
// mock/msw/handlers/user.ts
import { rest } from "msw";
export const handlers = [
rest.post<{ mail: string }>(
"https://api-server.com/user",
(req, res, ctx) => {
const data = req.body;
if (data.mail === "example-409@gmail.com") {
return res(
ctx.status(409),
ctx.json({
err: {
message: "conflict",
items: [["mail", "登録済みのアドレスです"]],
},
})
);
}
return res(ctx.status(201), ctx.json(data));
}
),
];
この handler 関数を test に設置します。ブラウザ・Node を意識せずに再利用できるため便利です。
// index.test.tsx
import { handlers } from "@/mock/msw/handlers/user";
import { composeStories } from "@storybook/testing-react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { setupServer } from "msw/node";
import React from "react";
import * as stories from "./index.stories";
describe("components/templates/UserCreate", () => {
// @ts-expect-error
const { Invalid409, Valid201 } = composeStories(stories);
// MSW の設定
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("登録済みメールアドレスの場合、エラーが表示される", async () => {
render(<Invalid409 />);
await Invalid409.play();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("登録済みのアドレスです");
});
test("登録が成功した場合、OKが表示される", async () => {
render(<Valid201 />);
await Valid201.play();
await waitFor(() => expect(screen.getByText("OK")).toBeInTheDocument());
});
});
msw-storybook-addon
で Story の parameters に同じ handler 関数を登録します。play 関数は、入力する文字列(送信する値)を調整することで、レスポンス出し分けを実現します。
// index.stories.tsx
import { handlers } from "@/mock/msw/handlers/user";
import type { ComponentStoryObj } from "@storybook/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserCreate } from "./";
type Story = ComponentStoryObj<typeof UserCreate>;
export default { component: UserCreate };
const playFactory = (mail: string) => async () => {
userEvent.type(screen.getByPlaceholderText("姓"), "田中");
userEvent.type(screen.getByPlaceholderText("名"), "太郎");
userEvent.type(screen.getByPlaceholderText("メールアドレス"), mail);
userEvent.click(screen.getByRole("button"));
};
export const Invalid409: Story = {
storyName: "異常系レスポンス",
play: playFactory("example-409@gmail.com"),
parameters: { msw: handlers },
};
export const Valid201: Story = {
storyName: "正常系レスポンス",
play: playFactory("example-201@gmail.com"),
parameters: { msw: handlers },
};
Component 内訳
import { UserCreateForm } from "@/components/organisms/UserCreateForm";
import React from "react";
import { useUserCreate } from "./useUserCreate";
export const UserCreate = () => {
const [values, handlers] = useUserCreate();
return (
<div>
<UserCreateForm
errors={values.errors}
handleSubmit={handlers.handleSubmit}
/>
{values.succeed && "OK"}
</div>
);
};
import type { Fields } from "@/components/organisms/UserCreateForm";
import axios from "axios";
import React from "react";
type State = {
errors: Partial<Fields> | undefined;
succeed: boolean | undefined;
};
const reducer = (state: State, action: Partial<State>): State => ({
...state,
...action,
});
export function useUserCreate() {
const [state, dispatch] = React.useReducer(reducer, {
errors: undefined,
succeed: undefined,
});
const handleSubmit = async (data: Fields) => {
try {
const res = await axios.post("https://api-server.com/user", data);
dispatch({
errors: undefined,
succeed: true,
});
} catch (err) {
if (!axios.isAxiosError(err)) throw err;
dispatch({
errors: Object.fromEntries(err.response?.data.err.items),
succeed: false,
});
}
};
return [state, { handleSubmit }] as const;
}
総括
CSF3.0 の登場で、Storybook 駆動開発がより効果を発揮しそうです。テストを書くのであれば、はじめに Story をコミットしてみてはいかがでしょうか。ここでは紹介しきれていませんが、VRT や E2E テストでも、その資材は再利用可能なものになるはずです。
Discussion