📋

コンポーネント設計に大事なことは全部テストが教えてくれる

に公開

0. はじめに

ログラスのフロントエンド基盤チーム所属の gege4 です。

対象読者

  • コンポーネント設計に迷いがちな方
  • 普段フロントエンドのテストをあまり実装しない方
  • いつもなんかコンポーネントが複雑になっちゃう方

TL;DR;

コンポーネントのテストコードを日頃から書くとコンポーネントの作り方が自然と整っていくよねっていう話。

1. TODO アプリを例に考える

例えば以下のような TODO アプリを例に考えてみましょう。

API から受け取った Todo のアイテムごとにisDoneフラグがあり、
true の時に、UI 上は"完了"のバッチがつく仕様とします。

コンポーネントを簡単に実装するとこんな感じ(雑)。

const TodoListTable = () => {
  // Todo一覧情報取得
  const { data, isLoading } = useSWR<TodoItem[]>("/todo-list", fetchTodoList);
  if (isLoading) return <>Loading...</>;

  return (
    <>
      {/* HeaderとかTodo入力欄とか... */}
      {data.map((todo) => (
        <Card key={todo.id}>
          {/* TodoアイテムのcheckboxやテキストのUI... */}
          {todo.isDone && <Badge>完了</Badge>}
        </Card>
      ))}
    </>
  );
};

この時、この仕様を持ったコンポーネントに単体テストを実装すると以下のようになると思います。

it("APIからisDoneをtrueで受け取った時に完了バッチが表示されること", async () => {
  // mswでAPIをモック化し、レスポンスボディを定義
  server.use(rest.get("/todo"), (_, res, ctx) => {
    return res(
      ctx.json({
        items: [
          { /* 0番目が未完了のアイテム */ ..., isDone: false},
          { /* 1番目が完了のアイテム */ ..., isDone: true}
        ],
      })
    );
  });

  render(<TodoListTable />)
  expect(await findTodoItem(0)).not.toHaveTextContent("完了");
  expect(getTodoItem(1)).toHaveTextContent("完了");
});

2. このテストが何を意味するか

ここからが本題です。
このテストが意味するところは、以下のとおりです。

「定義した mock データを返す API が存在する時、テーブルの完了フラグの UI はアサーションの通りとなる」

そもそも、コンポーネントをレンダリングするには API が必要なことを把握していないと成り立たないテストコードになっていますね。

つまり、このコンポーネントでは API と UI が密接になっていることを証明しています。
しかし、実務で扱うアプリケーションの場合、
いかに UI 担保の工数を下げられるかは、いかに UI 以外の関心ごとが分離されているかに左右されます。

例えば、この場合だと「API の仕様変更」にも「デザイナーからの仕様変更」にも発生する作業はこのコンポーネントの改修になってしまいます。

3. あるべきテストに立ち返る

ここで、本来実装したい単体テストを考えてみると、
与えられたデータに対して UI がどうあるか」であり、与えられる 「API」 が主語ではないはずです。
(コンポーネントを、 UI を取得する純粋関数としたときにその入力が API そのものでありたくないよね、という意)

与えられたデータとはコンポーネントにおいて詰まるところ Props ですね。
それを反映したテストが下記のようになると思います。

const MOCK_DATA = [
  { /* 0番目が未完了のアイテム */ ..., isDone: false},
  { /* 1番目が完了のアイテム */ ..., isDone: true}
] as const satisfies TodoItem[]

it("isDoneがtrueの時に完了バッチが表示されること", async () => {
  render(<TodoListTable items={MOCK_DATA} />)

  expect(getTodoItem(0)).not.toHaveTextContent("完了");
  expect(getTodoItem(1)).toHaveTextContent("完了");
});

いたってシンプルなテストコードになりました。
コンポーネントの内部実装を見ずとも、テストコードがこのコンポーネントの仕様を明らかにできています。

4. あるべきテストから紐解くあるべきコンポーネント像

このテストコードから導かれたコンポーネントが何を意味するかというと、

そうです、 Container / Presentational パターンですね。

https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern

実際に適応した TodoListTable コンポーネント
const TodoListTableContainer = () => {
  // Todo一覧情報取得
  const { data, isLoading } = useSWR<TodoItem[]>("/todo-list", fetchTodoList);
  if (isLoading) return <>Loading...</>;

  return <TodoListTable items={data} />;
};
const TodoListTable: React.FC<{ items: TodoItem[] }> = ({ items }) => {
  return (
    <div>
      {items.map((todo) => (
        <Card key={todo.id}>
          {/* TodoアイテムのcheckboxやテキストのUI... */}
          {todo.isDone && <Badge>完了</Badge>}
        </Card>
      ))}
    </div>
  );
};

「API 連携部分を container に切り出し、
あくまで UI に必要なデータだけを注入できるようにし、
その入力に対する純粋関数として UI コンポーネント(presentational)を定義する」、

という設計に、書きたいテストを求めると自然に辿り着くのではないでしょうか。

おわりに

今回は簡単な例示でしたが、この「isDone→ 完了 UI」の変換ロジックが実務ではどんどん複雑なものになります。
そんな中で、 UI のための複雑なロジックを持つコンポーネントにテストを実装していくと、
自然とテストを実装しやすい I/O を持つコンポーネント、つまり関心が分離された設計になると思います。

  • Props が複雑 or 多いコンポーネントはテストコードも事前準備が嵩張って読みづらいよね
  • テスト環境での DOM 要素取得を適切に行うにはコンポーネントに a11y 対応が必要になるよね

といった話もテストを書いていると自然とクリアにしたくなるっていう話もできそうですね。

We Are Hiring!

  • テスト実行基盤の整備(mock 化されたテストのしやすさや、実行速度の課題解消)
  • 共通コンポーネントが整備されテスト上で DOM 要素の取得が容易

などの課題がクリアになっているかは実際問題、feature 開発チームがテストを実装する上でボトルネックになってきます。

ログラスには事業のスケールに合わせてフロントエンド開発基盤をスケールさせるために、
基盤開発チーム内にフロントエンド専任のチームが存在しています。
ご興味持たれた方はぜひお話しましょう!
https://hrmos.co/pages/loglass/jobs/Eng-FE-001

株式会社ログラス テックブログ

Discussion