🏟

Storybook CSF の再利用・テスト拡充

2022/02/17に公開約15,900字

Storybook CSF は「再利用」を目的として策定されているよ、という文献をよく見ると思います。スプレッド構文で設定を継承するのはすぐ理解できることですが、CSF にはスプレッド構文以外にも有益な「再利用」の可能性が含まれています。これは、テスト拡充と深く関わる話です。

Jest + Testing Library 最大の欠点は「盲目」なこと

Jest + Testing Library でテストを書いているとき、期待通りの UI 状態を再現できず、時間を溶かした経験はないでしょうか?これは、実際にブラウザで UI を確認しながら書くテスト(Cypress など)との大きな差です。

CSF3.0 で導入された Play function でインタラクションを与えることは「目視でテストケースを作れる」と捉える事が出来るので、Jest + Testing Library のテストがぐっと書きやすくなります。

ここに、よくあるフォームの例があります。FillAllは、全ての入力フォームに値を埋めた状態の Story です。Jest + Testing Library と同じように、ユーザー操作を再現します。

Before
export const FillAll: Story = {
  name: "全て入力した時",
  play: async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fireEvent.change(ui.getByLabelText("氏名"), {
      target: { value: "山田太郎" },
    });
    fireEvent.change(ui.getByLabelText("年齢"), {
      target: { value: "27" },
    });
    fireEvent.change(ui.getByLabelText("メール"), {
      target: { value: "test@example.com" },
    });
    fireEvent.change(ui.getByLabelText("電話番号"), {
      target: { value: "080-000-0000" },
    });
  },
};

余談ですが、もっとコードを短くしたい場合、以下の様にfillTextboxという関数を用意すれば、だいぶ見通しがよくなります。

After
export const FillAll: Story = {
  name: "全て入力した時",
  play: async ({ canvasElement }) => {
    const ui = within(canvasElement);
    [
      { name: "氏名", value: "山田太郎" },
      { name: "年齢", value: "27" },
      { name: "メール", value: "test@example.com" },
      { name: "電話番号", value: "080-000-0000" },
    ].forEach((values) => fillTextbox(ui, values));
  },
};

Play function はユーザーインタラクションの通過点

続いて、フォームの挙動を含む Story を作ります。以下の Story で、送信完了したところまでの状態を作ることができます。しかし、さきのFillAllFillAllAndSubmitは「フォームを全入力する」というインタラクションが重複しています。

Before
export const FillAllAndSubmit: Story = {
  name: "全て入力し送信した時",
  play: async ({ canvasElement }) => {
    const ui = within(canvasElement);
    [
      { name: "氏名", value: "山田太郎" },
      { name: "年齢", value: "27" },
      { name: "メール", value: "test@example.com" },
      { name: "電話番号", value: "080-000-0000" },
    ].forEach((values) => fillTextbox(ui, values));
    // 送信ボタンを押下するインタラクション
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  },
};

そこで、すでに定義してあるFillAllの Play function を再利用します。以下のように引数であるctxをそのまま Play function に渡せば、FillAllの終点から再開することができます。

After
export const FillAllAndSubmit: Story = {
  name: "全て入力し送信した時",
  play: async (ctx) => {
    await FillAll.play?.(ctx); // <- 別StoryのPlay Function終点から再開
    const ui = within(ctx.canvasElement);
    // 送信ボタンを押下するインタラクション
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  },
};

要約すると、Paly function をつなげる(再利用する)ことで「一連のユーザーインタラクションにおける通過点」を再現することができます。 ここに示した例は「フォームを全入力・送信」という2つの通過点を再現しているということです。

高階関数で任意の Play function をインサート

ここまでで「フォームを全入力・送信」という2つの Story を作りました。続いて、react-hook-form & resolver などで実装されたフロントエンドバリデーションが、期待通りに動いているか見ていきましょう。以下の2つは「フォームを全入力・異常値を再入力・送信」を試みています。それぞれ期待どおり、エラー表示することが出来ています。

Before
export const NameError: Story = {
  name: "名前未入力時",
  play: async (ctx) => {
    await FillAll.play?.(ctx);
    const ui = within(ctx.canvasElement);
    fillTextbox(ui, { name: "氏名", value: "" }); // <- 異常値を再入力
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  },
};
export const AgeError: Story = {
  name: "年齢が数値でない時",
  play: async (ctx) => {
    await FillAll.play?.(ctx);
    const ui = within(ctx.canvasElement);
    fillTextbox(ui, { name: "年齢", value: "あいう" }); // <- 異常値を再入力
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  },
};

ここで「フォームを全入力・送信」という同じインタラクションが散在していることが気になりますね。異常値挿入前後のインタラクションが変わった時、diff が多くなることが想定できます。

そこで、fillAndSubmitという高階関数を用意します。この高階関数を通すことで、任意の Paly function を「フォームを全入力した後・送信する前」にインサートすることができます。

After
export const NameError: Story = {
  name: "名前未入力時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "氏名", value: "" });
  }),
};
export const AgeError: Story = {
  name: "年齢が数値でない時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "年齢", value: "あいう" });
  }),
};

高階関数の内訳がこちら。insertPlayが、引数として受け取っている Paly function です。この様に高階関数を中継することで、重複インタラクションを削減することができます。

type Context = StoryContext<ReactFramework, unknown>;
const fillAndSubmit =
  (insertPlay?: (ctx: Context) => Promise<void>) => async (ctx: Context) => {
    await FillAll.play?.(ctx);
    await insertPlay?.(ctx);
    const ui = within(ctx.canvasElement);
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  };

insertPlay引数は optional なので、何も渡さなければ通常の「全入力・送信」が行われることになります。

export const SucceedSubmit: Story = {
  name: "全て入力し送信した時",
  play: fillAndSubmit(),
};

MSW の再利用

MSW を使うと、Storybook でも Http リクエストのインターセプトが可能になります。表示しただけで Http リクエストが飛んでしまうコンポーネントに MSW は便利です。今回のサンプルはフォームを送信するものなので、正常系・異常系を再現できる handler を用意・2つの Story を export します。

export const SucceedSubmit: Story = {
  name: "送信が成功した時",
  play: fillAndSubmit(),
  parameters: {
    msw: {
      handlers: [
        rest.post("http://api.example.com", (req, res, ctx) =>
          res(ctx.status(201), ctx.json(req.body))
        ),
      ],
    },
  },
};
export const FailedSubmit: Story = {
  name: "送信が失敗した時",
  play: fillAndSubmit(),
  parameters: {
    msw: {
      handlers: [
        rest.post("http://api.example.com", (_, res, ctx) =>
          res(ctx.status(400), ctx.json({ errors: ["不正なリクエストです"] }))
        ),
      ],
    },
  },
};

さて、今回の再利用先ですが Jest 側になります。まずは、ここまでで実装した Story を Jest でアサートする例をみてみましょう。各 Story は状態が作り込まれているので、どの Story も「レンダー・プレイ・アサート」で十分ということになります。

import { composeStories } from "@storybook/testing-react";
const { NameError } = composeStories(stories);

describe("components/Form", () => {
  test("名前未入力時、警告が表示されること", async () => {
    // レンダー・プレイ・アサート
    const { container, findByRole } = render(<NameError />);
    await NameError.play({ canvasElement: container });
    expect(await findByRole("alert")).toHaveTextContent("入力してください");
  });
});

このとき、composeStoriesでは MSW の設定までは合成してくれません。しかし、Story に与えられたparametersはそのまま残っているため、以下の様にserver.useの資材として再利用することができます。

【2022/7/16 訂正】composeStories は、Story の MSW 設定も合成してくれます。 テストケースで個別にインターセプトする必要はありません。@storybook/testing-reactの作者である Yann Braga 氏がこの記事を読んでくれ、直々に PR を送ってくれました。感謝です。

import { composeStories } from "@storybook/testing-react";
const { FailedSubmit } = composeStories(stories);

describe("components/Form", () => {
  test("送信が失敗した時、警告が表示されること", async () => {
    // レンダー・プレイ・アサート
    const { container, findByRole } = render(<FailedSubmit />);
    await FailedSubmit.play({ canvasElement: container });
    expect(await findByRole("alert")).toHaveTextContent("不正なリクエストです");
  });
});

MSW がSeamlessly reuseをうたっているのは偶然の一致ではなく、フロントエンド Testing エコシステム全体が「再利用性を高め、保守運用コストをさげるべき」という方向を向いていることが分かります。

まとめ

今回の様に Storybook 側(CSF)にテスト資材を集めてしまえば、Jest は「レンダー・プレイ・アサート」で十分、ということを紹介しました。

  • フロントエンドによる送信前の、バリデーションエラー表示
  • サーバーサイドから受領した、バリデーションエラー表示

という、テストコードとは思えないほどForm.test.tsxはスッキリしているので、以下ご参考まで。

Form.tsx 詳細をみる
import React from "react";
import { useForm } from "react-hook-form";
import { Error } from "../Error";
import { resolver } from "./resolver";
// ____________________________________________________________
//
const defaultValues = {
  name: "",
  age: "",
  email: "",
  phone: "",
};
// ____________________________________________________________
//
export const Form = () => {
  const { register, handleSubmit, formState } = useForm({
    resolver,
    defaultValues,
  });
  const [errors, setErrors] = React.useState<string[]>([]);
  return (
    <form
      onSubmit={handleSubmit(async (values) => {
        setErrors([]);
        const data: { errors?: string[] } = await fetch(
          "http://api.example.com",
          {
            method: "POST",
            body: JSON.stringify(values),
            headers: { "Content-Type": "application/json" },
          }
        )
          .then((res) => {
            if (!res.ok) throw res;
            return res.json();
          })
          .catch((err) => {
            if (err instanceof Response) {
              return err.json();
            }
            throw err;
          });
        if (data.errors) {
          setErrors(data.errors);
        }
      })}
    >
      <table>
        <tbody>
          <tr>
            <th>
              <label htmlFor="name">氏名</label>
            </th>
            <td>
              <input {...register("name")} id="name" />
              <Error error={formState.errors.name?.message} />
            </td>
          </tr>
          <tr>
            <th>
              <label htmlFor="age">年齢</label>
            </th>
            <td>
              <input {...register("age")} id="age" />
              <Error error={formState.errors.age?.message} />
            </td>
          </tr>
          <tr>
            <th>
              <label htmlFor="email">メール</label>
            </th>
            <td>
              <input {...register("email")} id="email" />
              <Error error={formState.errors.email?.message} />
            </td>
          </tr>
          <tr>
            <th>
              <label htmlFor="phone">電話番号</label>
            </th>
            <td>
              <input {...register("phone")} id="phone" />
              <Error error={formState.errors.phone?.message} />
            </td>
          </tr>
        </tbody>
      </table>
      {!!errors.length && (
        <div>
          {errors.map((err, i) => (
            <Error key={i} error={err} />
          ))}
        </div>
      )}
      <div>
        <button>submit</button>
      </div>
    </form>
  );
};
Form.stories.tsx 詳細をみる
import type {
  ComponentStoryObj,
  ReactFramework,
  StoryContext,
} from "@storybook/react";
import { fireEvent, within } from "@storybook/testing-library";
import { rest } from "msw";
import { Form } from "./Form";
// ____________________________________________________________
//
type Story = ComponentStoryObj<typeof Form>;
export default { component: Form };
// ____________________________________________________________
//
const fillTextbox = (
  ui: ReturnType<typeof within>,
  { name, value }: { name: string; value: string }
) => {
  const textbox = ui.getByLabelText(name);
  fireEvent.change(textbox, { target: { value } });
};
export const FillAll: Story = {
  name: "全て入力した時",
  play: async ({ canvasElement }) => {
    const ui = within(canvasElement);
    [
      { name: "氏名", value: "山田太郎" },
      { name: "年齢", value: "27" },
      { name: "メール", value: "test@example.com" },
      { name: "電話番号", value: "080-000-0000" },
    ].forEach((values) => fillTextbox(ui, values));
  },
};
// ____________________________________________________________
//
type Context = StoryContext<ReactFramework, unknown>;
const fillAndSubmit =
  (insertPlay?: (ctx: Context) => Promise<void>) => async (ctx: Context) => {
    await FillAll.play?.(ctx);
    await insertPlay?.(ctx);
    const ui = within(ctx.canvasElement);
    fireEvent.click(ui.getByRole("button", { name: /submit/i }));
  };
// ____________________________________________________________
//
export const NameError: Story = {
  name: "名前未入力時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "氏名", value: "" });
  }),
};
export const AgeError1: Story = {
  name: "年齢が数値でない時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "年齢", value: "あいう" });
  }),
};
export const AgeError2: Story = {
  name: "年齢が整数でない時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "年齢", value: "27.8" });
  }),
};
export const AgeError3: Story = {
  name: "年齢が自然数でない時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "年齢", value: "-18" });
  }),
};
export const MailError: Story = {
  name: "メールが不正なフォーマットの時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "メール", value: "あいう" });
  }),
};
export const PhoneError: Story = {
  name: "電話番号が不正なフォーマットの時",
  play: fillAndSubmit(async ({ canvasElement }) => {
    const ui = within(canvasElement);
    fillTextbox(ui, { name: "電話番号", value: "あいう" });
  }),
};
export const SucceedSubmit: Story = {
  name: "送信が成功した時",
  play: fillAndSubmit(),
  parameters: {
    msw: {
      handlers: [
        rest.post("http://api.example.com", (req, res, ctx) =>
          res(ctx.status(201), ctx.json(req.body))
        ),
      ],
    },
  },
};
export const FailedSubmit: Story = {
  name: "送信が失敗した時",
  play: fillAndSubmit(),
  parameters: {
    msw: {
      handlers: [
        rest.post("http://api.example.com", (_, res, ctx) =>
          res(ctx.status(400), ctx.json({ errors: ["不正なリクエストです"] }))
        ),
      ],
    },
  },
};
Form.test.tsx 詳細をみる
import { composeStories } from "@storybook/testing-react";
import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react";
import React from "react";
import * as stories from "./Form.stories";
// ____________________________________________________________
//
const {
  NameError,
  AgeError1,
  AgeError2,
  AgeError3,
  MailError,
  PhoneError,
  FailedSubmit,
} = composeStories(stories);
// ____________________________________________________________
//
describe("components/From", () => {
  describe("フロントエンド バリデーション", () => {
    test("名前未入力時、警告が表示されること", async () => {
      const { container, findByRole } = render(<NameError />);
      await NameError.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent("入力してください");
    });
    test("年齢が数値でない時、警告が表示されること", async () => {
      const { container, findByRole } = render(<AgeError1 />);
      await AgeError1.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "数値を入力してください"
      );
    });
    test("年齢が整数でない時、警告が表示されること", async () => {
      const { container, findByRole } = render(<AgeError2 />);
      await AgeError2.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "整数を入力してください"
      );
    });
    test("年齢が自然数でない時、警告が表示されること", async () => {
      const { container, findByRole } = render(<AgeError3 />);
      await AgeError3.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "正の数を入力して下さい"
      );
    });
    test("メールが不正なフォーマットの時、警告が表示されること", async () => {
      const { container, findByRole } = render(<MailError />);
      await MailError.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent("不正な形式です");
    });
    test("電話番号が不正なフォーマットの時、警告が表示されること", async () => {
      const { container, findByRole } = render(<PhoneError />);
      await PhoneError.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent("不正な形式です");
    });
  });
  describe("サーバーサイド バリデーション", () => {
    test("送信が失敗した時、警告が表示されること", async () => {
      const { container, findByRole } = render(<FailedSubmit />);
      await FailedSubmit.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "不正なリクエストです"
      );
    });
  });
});

https://github.com/takefumi-yoshii/reusable-story-playfn

jest の mock がなければ到達できないエッジケースもあると思いますが、Play functions でかなりの事が出来るのではないでしょうか。Story がこのまま VRT 資材にもなりますし「Story が壊れていたらテストも落ちる」という構造になるので、効率が良いように思います。

Discussion

ログインするとコメントできます