🏞️

私のフロントエンドディレクトリ構成・テスト観点 2022

2022/06/24に公開

近日連投していた Next.js 記事のサンプルコードを公開しました。このサンプルコードを元に、私のフロントエンドディレクトリ構成・テスト観点を紹介します(あくまで執筆現在の脳内アウトプットになりますのでご了承ください)

https://github.com/takefumi-yoshii/nextjs-testing-strategy-2022

フロントエンドディレクトリ構成の事情

タイトルの「フロントエンドディレクトリ構成」をさす「Components」のディレクトリ構成は、いつも悩みのタネです。このモジュールシステムは「デザインシステム観点・アクセシビリティ観点・フロントエンド実装観点」の 3 つの観点が混在するため事情が複雑です。どうせ作るのなら「デザイナー・フロントエンド」どちらの開発基盤にもなりえる、盤石なモジュールシステムを目指したいですよね。

"AtomicDesign やめました"という声をたまに聞くのですが「デザインシステム的に捨てていいの?」と思うこともあるので、とくに要望がなければ、筆者は「AtomicDesign & フロント都合の +α」構成にしています。「+α」というのは、例えば「hooks・layouts」のような、実装観点で必要になるコンポーネント群をさします。

├── atoms
├── hooks     // Custom hooks
├── layouts   // Next.js getLayout Components
├── molecules
├── organisms
└── templates

AtomicDesign わかりにくい問題

とはいえ、AtomicDesign は分類に困ることが多いのが特に問題視されています。人によって「molecules / organisms」の判断がバラバラだったり「なんでこれがここに?」という根拠のない仕分けがされていたり…何か基準が欲しいところです。そこで筆者は 「迷ったらアクセシビリティ軸で判断する」 ようにしています。

その判断基準は、本記事の主題である「テスト」が関係しています。テストコードを書くと、そのモジュールの責務に自信がもてるようになり、メンバー間で共通認識がうまれます。昨今の UI コンポーネントのテストは「role 属性&アクセシブルネーム」でクエリーを書くのが一般的になり、テストコードを比較すると「このコンポーネントはこのグループっぽいな」という毛色が、テストコードから見えてきます。

Components「アクセシビリティ観点のテストを書く」

それぞれのグループに対し、アクセシビリティ観点でテストを書くと、どういった内容になるのかを確認していきます。

Atoms

「意味的単一要素」 で構成されるコンポーネント置き場です。デザイン上では複合要素に見えても、意味的には単一要素として表現したいのであれば atoms と判断します。テストコードでは、意図した role 属性でクエリできるかを確認します。

describe("src/components/atoms/Button/Button.test.tsx", () => {
  test("[role=button]であること", () => {
    const { getByRole } = render(<Default />);
    expect(getByRole("button", { name: "送信する" })).toBeInTheDocument();
  });
});
describe("src/components/Textbox/Textbox.test.tsx", () => {
  test("[role=textbox]であること", () => {
    const { getByRole } = render(<Default />);
    expect(getByRole("textbox")).toBeInTheDocument();
  });
});

品質観点では「見た目は checkbox なのに、a11y 上では checkbox になっていない」というミスが稀にあるので、こういった考慮漏れを、テストを書いて防ぎます。

Molecules

「意味的複合要素」 で構成されるコンポーネント置き場です。aria-describedbyaria-errormessageを用いて、内部で複数要素が関連付けられている場合、molecules と判断します。テストコードでは、関連付けが意図通り機能しているかを確認します。listロールなど、子要素を持つ前提のコンポーネントも molecules に分類します。

src/components/TextboxWithTitle/TextboxWithTitle.test.tsx
describe("src/components/TextboxWithTitle/TextboxWithTitle.test.tsx", () => {
  const options: ByRoleOptions = { name: "お名前" };
  test("labeltext が textbox のアクセシブルネームであること", () => {
    const { getByRole } = render(<Default />);
    const textbox = getByRole("textbox", options);
    expect(textbox).toBeInTheDocument();
  });
  test("description で textbox が識別されていること", () => {
    const { getByRole } = render(<HasDescription />);
    const textbox = getByRole("textbox", options);
    // aria-describedby が機能していることを確認
    expect(textbox).toHaveAccessibleDescription("姓名を入力してください");
  });
  test("error で textbox が識別されていること", () => {
    const { getByRole } = render(<HasError />);
    const textbox = getByRole("textbox", options);
    // aria-errormessage が機能していることを確認
    expect(textbox).toHaveErrorMessage("入力エラーがあります");
    expect(textbox).toBeInvalid();
  });
});

Organisms

「意味的主要要素」 を担うコンポーネント置き場です。Landmark RolesWindow Rolesなどが相当します(例外として、search ロール要素など、デザインシステム観点から見て organisms として不自然なものはここに置きません)また、後続の理由から、main ロールは organisms に含めないようにします。

describe("src/components/organisms/BasicAside/BasicAside.test.tsx", () => {
  test("[role=complementary]であること", () => {
    const { getByRole } = render(<Default />);
    expect(getByRole("complementary")).toBeInTheDocument();
  });
});
describe("src/components/organisms/BasicHeader/BasicHeader.test.tsx", () => {
  test("[role=banner]であること", () => {
    const { getByRole } = render(<Default />);
    expect(getByRole("banner")).toBeInTheDocument();
  });
});

Templates

templates は、pages と一対一になるコンポーネント置き場です。Next.js の getServerSideProps などで取得したデータを流し込みます。ここに分類されるコンポーネントは、ルートが必ず<main>タグとしてます。文書ごとに main ロールを 1 つのみ使用するべきなので、このコンポーネントのルートにすると都合が良いように感じています。もし子コンポーネントに<main>を含めてしまった場合は「複数要素がマッチした」という理由で、このテストは落ちるでしょう。

src/components/templates/UserEdit/UserEdit.test.tsx
describe("src/components/templates/UserEdit/UserEdit.test.tsx", () => {
  const server = setupMockServer(updateUserHandler());
  test("main ランドマークを1つ識別できること", () => {
    const { getByRole } = render(<Default />);
    const main = getByRole("main");
    expect(main).toBeInTheDocument();
  });
});

テストを書くにしても、アクセシビリティツリーがよく分からない場合、開発ツールを少し掘り下げると理解がはかどると思います。先日この件について投稿しましたのでご参考まで。

非同期処理「分割して責務ごとにテストを書く」

さて、アクセシビリティ観点以外にも「フロントエンド実装観点」が UI コンポーネント設計には紛れ込んできます。たとえば「非同期処理をどこで扱うか」という話題です。筆者は、実装・テストともに「非同期処理は分割する」ようにしています。

つぎの 2 コンポーネントは「organisms / templates」で分割された、親子コンポーネントです。子の Form コンポーネントは値を送る前に「事前バリデーション」を行ない、不正入力値は送信しません。子のバリデーションを通過すると、props 経由で親のコールバックハンドラーが API を叩く、という構図です。

【API 非依存の子コンポーネント】

src/components/organisms/UserForm/UserForm.test.tsx
describe("src/components/organisms/UserForm/UserForm.test.tsx", () => {
  test("空で送信した場合、入力を促すエラーメッセージが表示されること", async () => {
    const { container, getByRole } = render(<EmptyPost />);
    await EmptyPost.play({ canvasElement: container });
    await waitFor(() => {
      expect(getByRole("textbox", { name: "ユーザー名" })).toHaveErrorMessage(
        "ユーザー名を入力してください"
      );
    });
    expect(getByRole("textbox", { name: "メールアドレス" })).toHaveErrorMessage(
      "メールアドレスを入力してください"
    );
  });
  test("不正な文字列で送信した場合、バリデーションエラーメッセージが表示されること", async () => {
    const { container, getByRole } = render(<InvalidInputs />);
    await InvalidInputs.play({ canvasElement: container });
    await waitFor(() => {
      expect(
        getByRole("textbox", { name: "メールアドレス" })
      ).toHaveErrorMessage("不正なメールアドレス形式です");
    });
  });
});

【API 依存の親コンポーネント】

src/components/templates/UserEdit/UserEdit.test.tsx
describe("src/components/templates/UserEdit/UserEdit.test.tsx", () => {
  const server = setupMockServer(updateUserHandler());
  describe("入力フォーム", () => {
    test("正常入力値で送信すると、API が呼ばれること", async () => {
      const mock = jest.fn();
      server.use(updateUserHandler({ mock }));
      const { getByRole } = render(<Default />);
      await typeData(getByRole);
      userEvent.click(getByRole("button", { name: "送信する" }));
      await waitFor(() =>
        expect(mock).toHaveBeenCalledWith(expect.objectContaining(actualData))
      );
    });
    test("正常入力値で送信した場合、成功した旨が表示され、ユーザー詳細画面に遷移すること", async () => {
      server.use(...storyHandlers(SucceedPost));
      const { container, findByRole } = render(<SucceedPost />);
      await SucceedPost.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "ユーザーの編集に成功しました"
      );
      expect(singletonRouter).toMatchObject({ asPath: "/users/1" });
    });
    test("エラーが返ってきた場合、エラーが表示されること", async () => {
      server.use(...storyHandlers(ServerError));
      const { container, findByRole } = render(<ServerError />);
      await ServerError.play({ canvasElement: container });
      expect(await findByRole("alert")).toHaveTextContent(
        "ユーザーの編集に失敗しました"
      );
    });
  });
});

親のテストコードに着目すると、setupMockServerという関数で、モックサーバーの初期化をしているのが見てとれます。これは API 依存テストコードの特徴です。このようなテストを書く事で「内部処理の結果なのか・外部依存の結果なのか」という意識がはっきりするので、自ずとコンポーネントの責務がはっきりします。

「API を叩く親コンポーネントのテストで、子コンポーネントのバリデーションテストを書かない」 ということが肝心です。それは、親コンポーネントのテスト範疇ではありません。「なんだかテストが書きづらい・ごちゃごちゃしてる」と感じたら、そもそものコンポーネント設計がおかしなサインなので、見直してみましょう。

※ 追記

「では、非同期処理は organisms ではなく全部 templates に寄せよう!」という話ではありません。この例では、そこに持たせるのが都合が良いためこうなっている、という一例です。「atoms/molecules/organisms/templates」以外にも「フロントエンドの実装都合で、どこにも該当しないグループが出来てしまって…」という状況になったら、自由に「+α」ディレクトリを増やしてしまっても良いと考えています。目指しているのは「デザイナー・フロントエンド」どちらの開発基盤にもなりえる、モジュールシステムなので。

SSR・API「結合テストを書く」

getServerSideProps や API Routes を備える Next.js アプリケーションの場合、MSW を使った結合テストを書きます。ネットワークレイヤーでモックするため、システムテストで網羅するまでもないテストケースに対し有用です。

getServerSideProps

与えられた middleware が機能し、コンポーネントが表示されるかを書きます。テストケースタイトルで表しているように、レスポンスによる分岐に対し、テストを書きます。

src/pages/users/[id]/edit.test.tsx
describe("src/pages/users/[id]/edit.test.tsx", () => {
  describe("getServerSideProps", () => {
    setupMockServer(getUserHandler());
    test("400:非数", async () => {
      const res = await getServerSideProps(gsspCtx({ query: { id: "abc" } }));
      assertHasProps(res);
      render(<Page {...res.props} />);
      expect(screen.getByRole("heading", { name: "400" })).toBeInTheDocument();
    });
    test("302:未ログイン", async () => {
      const res = await getServerSideProps(
        gsspCtx(undefined, undefined, false)
      );
      expect(res).toMatchObject({
        redirect: { basePath: undefined, destination: "/", permanent: false },
      });
    });
    test("200", async () => {
      const res = await getServerSideProps(gsspCtx({ query: { id: "123" } }));
      assertHasProps(res);
      render(<Page {...res.props} />);
      expect(
        screen.getByRole("heading", { name: "ユーザー編集" })
      ).toBeInTheDocument();
    });
  });
});

API Routes

与えられた middleware が機能し、意図したレスポンスが返ってくるかを確認します。

src/pages/users/[id]/edit.test.tsx
describe("src/pages/api/posts/[id].test.ts", () => {
  describe("PUT", () => {
    setupMockServer(updatePostHandler());
    test("401:未ログイン", async () => {
      const { status, json } = await testApiHandler(
        handler,
        { method: "PUT" },
        false
      );
      expect(status).toHaveBeenCalledWith(401);
      expect(json).toHaveBeenCalledWith(
        expect.objectContaining(errors["UNAUTHORIZED"])
      );
    });
    test("400:非数", async () => {
      const { status, json } = await testApiHandler(handler, {
        method: "PUT",
        query: { id: "abc" },
      });
      expect(status).toHaveBeenCalledWith(400);
      expect(json).toHaveBeenCalledWith(
        expect.objectContaining(errors["INVALID_PATH_PARAM"])
      );
    });
    test("400:負数", async () => {
      const { status, json } = await testApiHandler(handler, {
        method: "PUT",
        query: { id: "-123" },
      });
      expect(status).toHaveBeenCalledWith(400);
      expect(json).toHaveBeenCalledWith(
        expect.objectContaining(errors["INVALID_PATH_PARAM"])
      );
    });
    test("400:不正入力値", async () => {
      const { status, json } = await testApiHandler(handler, {
        method: "PUT",
        query: { id: "123" },
      });
      expect(status).toHaveBeenCalledWith(400);
      expect(json).toHaveBeenCalledWith(
        expect.objectContaining({
          ...errors["VALIDATION"],
          errors: expect.anything(),
        })
      );
    });
    test("200", async () => {
      const body = {
        title: "Lorem Ipsum",
        author: "takepepe",
        body: "",
        published: true,
        publishedAt: "2022-06-23T13:30:07.942Z",
      };
      const { status, json } = await testApiHandler(handler, {
        method: "PUT",
        query: { id: "123" },
        body,
      });
      expect(status).toHaveBeenCalledWith(200);
      expect(json).toHaveBeenCalledWith(
        expect.objectContaining({ post: postFactory(123) })
      );
    });
  });
});

コストが高い「大粒度の結合テスト」

「ブラックボックステストはコスパが良い」のはその通りなのですが「大粒度の結合テスト」に比重がよってしまうと、いくつかの弊害が後から見えてきます。

  • 非同期処理を含むロジックは遅く、CI 待ち時間に影響が出る
  • 大きな DOM render を含むテストは遅く、CI 待ち時間に影響が出る
  • モジュール境界が不明瞭になり、不要な重複テストを書きすぎてしまう
  • 重複した内容が散在するため、メンテナンスコストがあがる

以上のことから、内部構造に着眼した単体テスト(ホワイトボックステスト)であったり、小粒度の結合テストを、余裕に応じてどんどん書くことをお勧めします。「このケースは単体テストで十分そう」という判断の積み重ねが、全体最適化への一番の近道と筆者は考えます。

どういったテストケースが不足しているのかは、カバレッジレポートを見れば、すぐに検討がつきます。サンプルコードでもレポーターを仕込んでおいたので、内訳を確認してみてください。

$ yarn install
$ yarn test
$ oepn __reports__/jest.html
$ open coverage/lcov-report/index.html

最後に

ヘッドレスブラウザを用いた「ビジュアルリグレッションテスト・システムテスト」に関しては、今回のサンプルでは力尽きたため含められませんでした(もちろん Storybook は伏線ですが)これはまた別の機会に投稿したいと思います。

関連記事

Discussion