🎁

Custom Hooks as Presenter という考え方

2023/12/17に公開

これは React Advent Calendar 2023 の 17 日目の記事です。

TL;DR

次のように表示用データの生成ロジックをカスタムフックに閉じ込め、コンポーネントを非常に薄く作る。

function usePresenter(props: Props) {
  const retrieveDetails = useRetrieveDetails();
  const [isLoaded, setIsLoaded] = useState(false);

  const handleTap = useCallback(async () => {
    if (!isLoaded) {
      await retrieveDetails()
      setIsLoaded(true)
    }
  })

  return useMemo(() => {
    nickname: buildNickname(props),
    actions: {
      handleTap,
    },
  });
}

function MyComponent(props: Props) {
  const presenter = usePresenter(props);
  return <Text onPress={presenter.handleTap}>{presenter.nickname}</Text>;
}

こうすることでテストが書きやすくなり、メンテナンスも楽になる。

describe("usePresenter", () => {
  it("returns data to show", () => {
    const result = renderHook(() =>
      usePresenter({
        givenName: "Anya",
        familyName: "Forger",
      })
    );
    expect(result.current).toEqual({
      nickname: "Brave Anya",
      actions: {
        handleTap: expect.any(Function),
      },
    });
  });
});

解決したい課題

極力メンテナンスの労力をかけず、 React コンポーネントのテストを意味あるものとしたい。これが、まさに解決したい課題だ。

現状整理

React Hooks が導入されて以降、複雑な処理を関数型コンポーネントで実装することが可能になり、 React コンポーネント自体の複雑性が上がっている。

また、現在 React コンポーネントのテスト手法は様々なものが提案されている。

  • 仮想 DOM ベースのテスト
  • スナップショットテスティング
  • ビジュアルリグレッションテスティング

Hooks 導入によるコンポーネントの複雑性増加

Hooks が導入された React v16.18.0 以降、表示とロジックが密結合になりがちだ。公式の例において、コンポーネント内部にロジックが含まれているコードが紹介されていることもあり、そのデメリットを考えるタイミングなく真似をしてしまうこともあるだろう。

仮想 DOM ベースのテスト

レンダリングされた仮想 DOM に対して、特定の要素が含まれているかどうかをチェックするテストは機能することがあるが、 DOM 自体が変更されやすいためテストのメンテナンスが高くつく。

また、意味のあるテストとするために複数の要素をチェックしなければならないことが多く、テストの数が多くなりがちだ。

スナップショットテスティング / ビジュアルリグレッションテスティング

スナップショットテスティングビジュアルリグレッションテストはコンポーネントに意図しない差分が生じたことを検知できる。

が、 React コンポーネントがロジックを持つ場合、複雑なテストになってしまう可能性がある。また、変更が意図的かどうかを判別するために、人間がテスト結果を見なければならない。

テストの目的

さて、具体的に自分が何に困っているかというと、次の観点を担保したテストを書くのが難しいということだ。

  1. Bug Repellent: バグ避け
    • 複雑性が高くなりがちのためバグ発生の可能性が高くなっている
  2. Defect Localization: 欠陥の特定
    • 表示とロジックのどちらにバグがあるのか、ロジックのどこが原因なのかわかりづらい
  3. Fully Automated Test: 完全自動化されたテスト
    • スナップショットテスティングなどは成功か失敗かの判断が必要
  4. Simple Tests: シンプルなテスト
    • DOM ベースのテストはシンプルとなりづらい
  5. Separation of Concerns: 関心の分離
    • スナップショットテスティングは Hooks も同時にテスト対象としてしまう
  6. Robust Test: 変更に強いテスト
    • 表示とロジック、どちらが変わってもテストを修正しなければならない可能性が生じる

これらは xUnit Test Patterns において、ユニットテストが目指すべきものとして紹介されている概念の一部だ。

https://www.informit.com/store/xunit-test-patterns-refactoring-test-code-9780132800051

Custom Hooks as Presenter

この記事における造語で、冒頭で述べた課題を解決するための糸口となるものだ。

Presenter とは何か

カタカナで書くとプレゼンターとなる。 Clean Architecture におけるインターフェイスアダプターのひとつで、アプリケーションのビジネスルールから各種データを受け取り、 UI が使用するデータを生成する責務を持つ。

Clean Architecture 概要図

Presenter は、テスト可能なオブジェクトである。アプリケーションからデータを受け取り、プレゼンテーション用にフォーマットして、View が画面に移動できるようにする。

https://asciidwango.jp/post/176293765750/clean-architecture

React の文脈では、表示用のロジックを記述した関数として理解すればよいだろう。

Humble Object と Humble Dialog

そもそも Clean Architecture では、 UI を Humble Object として実装すると良いと書いてある。

GUI の振る舞いの大部分は、簡単にテストできる。Humble Object パターンを使えば、2 種類の振る舞いを Presenter と View の 2 つのクラスに分けられる。
View は、Humble Object である。こちらはテストが難しい。したがって、このオブジェクトのコードはできるだけシンプルに保っておく。GUI にデータを移動するが、そのデータを処理することはない。

React の文脈における Humble Object はいわゆる Dumb Component や Presentational Component と呼ばれているものに相当する。次のような、 props の値をそのまま表示するコンポーネントのことだ。

const DumbComponent: React.FC = (props: Props) => {
  return <Text>{props.name}</Text>;
};

これは先にも挙げた xUnit Test Patterns においても紹介されており、 UI に適用したバリエーションとして Humble Dialog という名前がつけられている。

we may benefit by using a Humble Object to move all of the controller and view-updating logic out of the framework-dependent object and into a testable object.

Presenter をカスタム Hook として実装する

React コンポーネント自体は素直に薄く作り、表示用ロジックをカスタム Hook として実装する。アイディアとしては単純だ。

このように分離することでカスタム Hook 単体のテストが可能となり、表示ロジックをテストしやすくなる。

実装例

例として、ブログエントリーをニュースヘッドラインのように表示するコンポーネントを書いている。

function usePresenter(props: Props) {   // ①
  const { viewerId } = useUserAttribute();
  const [ deletePost ] = useDeletePostMuation();

  return {
    posts: props.posts.map((post) => ({
      headline: `${post.emoji} ${post.title}`
      summary: summarizePost(post.body),
      postedAt: calculateDaysAgo(post.createdAt),
      numofComments: post.comments.length,
      actions: {
        delete: async () => {
          await deletePost(post.id)
        },
      },
    })),
  };
}

function News(props: Props) {
  const presenter = usePresenter(props);    // ②

  // ③
  return (
    <FlatList
      data={presenter.posts}
      renderItem={({ item }) => <Summary data={item} />}
    />
  )
}

function Summary(props: SummaryProps) {
  const { data } = props

  // ④
  return (
    <View>
      <Text>{data.headline}</Text>
      <View style={{ flex-direction: 'row' }}>
        <Text>{data.postedAt}</Text>
        <Text>{`${data.numofComments}個のコメント`}</Text>
      </View>
      <Text>{data.summary}</Text>
    </View>
  )
}

① でプレゼンターを定義し、② でそれを呼び出し、表示用データを生成している。③、④ における JSX の定義は非常に簡単なものとしているのがポイントだ。

テストでは、 usePresenter が生成するオブジェクトが意図したものであることをチェックすれば良い。

describe("usePresenter", () => {
  it("returns data to show", () => {
    const result = renderHook(() =>
      usePresenter([
        {
          id: 0,
          emoji: "🎅",
          title: "クリスマスイブ",
          createdAt: new Date("2023, 11, 24"),
          body: "サンタさんがんばれ",
          comments: ["Dasher", "Dancer", "Prancer", "Rudolf"],
        },
        {
          id: 1,
          emoji: "🎍",
          title: "お正月",
          createdAt: new Date("2024, 0, 1"),
          body: "お雑煮にいくつお餅いれる?",
          comments: ["ひとつ", "ふたつ", "みっつ"],
        },
      ])
    );
    expect(result.current).toEqual([
      {
        headline: "🎅 クリスマスイブ",
        summary: "サンタさんがんばれ",
        postedAt: "10 日前",
        numofComments: 4,
        actions: {
          delete: expect.any(Function),
        },
      },
      {
        headline: "🎍 お正月",
        summary: "お雑煮にいくつお餅いれる?",
        postedAt: "3 日前",
        numofComments: 3,
        actions: {
          delete: expect.any(Function),
        },
      },
    ]);
  });
});

上述した目的を達成することが可能となっている。

  1. Bug Repellent: バグ避け
    • 適切なユニットテストを書き、すべてパスしていればバグがない
    • もちろん、ある程度以上に複雑な場合はより小さな関数群に分解してテストすると良いだろう
  2. Defect Localization: 欠陥の特定
    • ロジック側にバグがあればすぐにわかる
    • 余裕があれば元ネタのクリーンアーキテクチャーで述べられている通り、表示ロジックとアプリ特有のロジックなどに分割すると、より良いだろう
  3. Fully Automated Test: 完全自動化されたテスト
    • ユニットテストの実行は完全に自動化されている
  4. Simple Tests: シンプルなテスト
    • プレゼンターによって生成されたオブジェクトが意図通りかどうかのテストのみ
  5. Separation of Concerns: 関心の分離
    • ロジックはユニットテストでカバーできる
    • 表示もテストしたい場合はビジュアルリグレッションテストなどを併用すると良い
  6. Robust Test: 変更に強いテスト
    • 入力と出力が変わらない限り、テストはそのままで使い回せる

まとめ

これは一昔前に Container / Presentational パターンとして実装されていたもののうち、 Container に当たる部分をカスタムフックとして実装しようというアイディアだ。

ただ、関数として切り出しただけで格段にメンテナンス、テストしやすくなる。

GitHubで編集を提案

Discussion