⚙️

Storybook 駆動開発 @ CSF3.0

2021/09/30に公開

Storybook CSF3.0 の概要

単体テスト・結合テスト・Storybook を充実させるためには、多くの工数が必要です。堅牢なフロントエンド開発のためとはいえ、これらのメンテナンスは日に日に負担が増しています。似かよったテストケースでは、同じような下作業をそれぞれに用意する必要がありました。

Component Story Format(CSF)は、この課題への取り組みとして開発されました。「様々なソリューションで再利用可能な資材」 が用意できれば、開発は素早く・より楽しいものになります。リリース間近の CSF3.0 はより一層、そのゴールを明確に示してくれています。

https://storybook.js.org/blog/component-story-format-3-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!");
});

詳細は本家の資料を参考にしてみてください。

https://github.com/storybookjs/testing-react

https://medium.com/storybookjs/testing-lib-storybook-react-8c36716fab86

実践 Storybook 駆動開発 @ CSF3.0

これより紹介するサンプルは、筆者が実践した内容です。「ユーザーを新規作成するフォーム」を想定して、Story と test を書きながら実装しました。

サンプルは以下のリポジトリで公開しています。(型定義まわりがまだ整っていないこともあり @ts-expect-error が利用されている箇所がありますがご了承ください)
https://github.com/takefumi-yoshii/csf3-playing-sandbox

【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