📦

フロントエンドアプリケーションの認知負荷とテスタビリティに立ち向かう

2023/12/06に公開

この記事は Timee Advent Calendar 2023 シリーズ1の6日目の記事です。

はじめに

こんにちは。タイミーでフロントエンドエンジニアをしている @yama_sitter です。

2023/09に入社したのでそろそろ入社エントリーを…と思っていましたが、それより先にアドベントカレンダーが始まってしまいました。
今年のタイミーのアドベントカレンダーは3トラック並行して走っています。意味が分かりません。すごい。

今回は「フロントエンドアーキテクチャに ”インタラクション層” を導入したら、認知負荷が下がってテスタビリティが上がった」という話を紹介させてください。

悩んでいたこと

前提として、タイミーのフロントエンドアプリケーションは以下の方針のもとで成り立っています。

  • フレームワークにNext.jsを利用
  • いわゆる「package by feature(※1)」に似た形でドメイン別のディレクトリが存在し、そのそれぞれの中にドメインに関連するコンポーネントやhooksを置く
  • ページレイヤーのコンポーネント(以下、Pageコンポーネントと呼称 (※2))で複数ドメインのコンポーネントやhooksを繋げ、複数のアプリケーションの知識を表現する
  • APIの戻り値や計算結果といったカスタムhooksの値はPageコンポーネントから下位コンポーネントに流す
    • このため下位コンポーネントは難しいロジックを持たない

この結果下記のようなPageコンポーネントが生まれます。
Foo というユースケースと、 Bar というユースケースと、それらを組み合わせたユースケースと、他にも幾つかのユースケースと…etc…。

import { useFoo } from 'domains/foo/useFoo';
import { FooComponent } from 'domains/foo/FooComponent'; // この辺はシンプル
import { useBar } from 'domains/bar/useBar';
import { BarComponent } from 'domains/bar/BarComponent'; // この辺もシンプル
... // 記述が続く

const PageComponent = () => {
  const { foo, ... } = useFoo();
  const { bar, ... } = useBar();
  const baz = useMemo(() => ..., [foo]);
  ... // 記述が続く

  if (!foo.xx) {
    ...
  }
  ... // 記述が続く

  return (
    <>
      <FooComponent zz={baz} />
      {!bar.yy ? (<BarComponent />) : null}

      ... {/* 色々なユースケースを実現ための処理やコンポーネントが続く */}
    </>
  );
};

このあり方はメリットも多いです。下位コンポーネントはおしなべて軽量になりますし。

ただ私は「機能が増えた結果あらゆるユースケースの知識がPageコンポーネントの1つにまとまってしまい、認知不可が高くテスタビリティが低くなっている」ということに悩まされていました。

このため小さく切り出そうと考えるのですが、今度は「”複数のドメインを跨いで表現されるユースケース” をどこでどう管理するか」ということに頭を捻ってしまっていました。

やったこと

ではどうしようか、ということで考えたのが “インタラクション層” です。
これはシンプルに言うと「アプリケーションのユースケースの単位に責任を持つ層」となります。

  • “ユースケース層” だと仮にrender hooksパターンを使いたいときに useXXUseCase みたいになって気持ち悪い
  • 「ユースケース = ユーザーとの一連のインタラクション」と捉えている

といった理由から “インタラクション層” と呼称しています。

“インタラクション層” の例

以下は具体的なイメージです。(実際のプロダクションコードではありません)
例)ユーザーを評価したら、成功の通知を出してユーザー情報を再度取得するユースケース

// ~/Pages/UserReview/UserReviewInteractor.tsx
// 本体でありロジックに責任を持つContainerコンポーネント
import { useCallback, FC } from 'react';
import { useToast } from '~/hooks/useToast';
import { useUser } from '~/domains/user/hooks';
import { useUserReview } from '~/domains/user-review/hooks';
import { PresentationalUserReviewInteractor } from './Interactor.presenter';

const ON_SUCCESS_MESSAGE = 'ユーザーの評価に成功しました。';

export type Props = {
  userId: number;
};

export const UserReviewInteractor: FC<Props> = {
  const { pushToastMessage } = useToast();
  const { userName, revalidateUser } = useUser(userId);

  // ユーザーの評価に成功した場合、通知を出してユーザー情報を更新する
  const onSuccess = useCallback(() => {
    pushToastMessage({
      type: 'success',
      message: ON_SUCCESS_MESSAGE,
    });

    revalidateUser();
  }, []);

  const { loading, succeeded, reviewUser } = useUserReview({
    userId,
    onSuccess,
  });

  // ユーザー評価後は評価機能を非表示にする
  if (succeeded) return null;

  return <PresentationalUserReviewInteractor {...{ loading, userName, reviewUser }} />;
};

// ~/Pages/UserReview/UserReviewInteractor.presenter.tsx
// 表示に責任を持つPresentationalコンポーネント
import type { FC } from 'react';
import { UserReviewSection } from '~/domains/user-review/UserReviewSection';
import { UserReviewButton } from '~/domains/user-review/UserReviewButton';

export type Props = {
  loading: boolean;
  reviewUser: () => void;
};

export const UserReviewInteractorPresenter: FC<Props> = ({
  loading,
  userName,
  reviewUser,
}) => {
  return (
    <UserReviewSection userName={userName}>
      <UserReviewButon loading={loading} onClick={reviewUser} />
    </UserReviewSection>
  );
};

このコードの振る舞いに対してテストを書けば、それはそのままユースケースを実現する仕様に対するテストになります。
今回は「振る舞い」を担保することを主眼に当てており、積極的にモックを使っています。このため他のユースケースを気にすることなくテストすることができました。

またContainer/Presentationalパターンを使って表現部分を切り出し、この表現部分の品質をStorybookで保証するようにしました。
このパターンを使わず頑張りたかったのですが、ストーリーファイルでhooksをモックするのはちょっと手間ですし、mswだと様々なUIのパターンに対応するために一工夫必要そうだったので今回は見送っています。

インタラクション層の存在意義

“インタラクション層” がAtomic DesignのOrganismsなどと何が違うのかという話になるんですが、これはシンプルに存在意義が異なります。

  • “インタラクション層” が担うべきは「ユースケース = アプリケーション知識」であり、使い回しを前提としていない
  • “インタラクション層” は複数ドメインの複雑な依存を束ねる
  • “インタラクション層” はページレイヤーの管轄であり、Organismsやドメインといった概念に属さない
    • 実際のアプリケーションでは ~/Pages/XXXPage/ といったPageコンポーネント用のディレクトリの下に置いている

責任を分解するのはごく普通のことです。
ただこれを単に分解するのではなく、 “インタラクション層” と命名し責任を明確にすることで、より理解しやすく作りやすいものにできたと思っています。

余談ですが、Pageコンポーネントに寄せる件は以下記事の「Layer型のディレクトリ」の話に近いと思います。
記事も良かったのでオススメです。
https://zenn.dev/mybest_dev/articles/c0570e67978673

結果

この取り組みは結果のような成果をもたらしました。

  • ユースケース単位でテストが書きやすくなった(同様にストーリーの記述が楽になった)
    • 結合テストやE2Eに頼らずとも、ユニットテストでユースケースの品質を最低限担保できる
    • これまでPageコンポーネントのテストに存在した多くのセットアップ記述が不要になり簡潔に
  • Pageコンポーネントの責任が減り、認知負荷が大きく下がった
    • Pageコンポーネントに散らばっていた「どんな機能を組み合わせて何を実現するか」という知識が “インタラクション層” に集約される
  • レビュアーから「分かりやすくなりレビューしやすかった」という声をもらった
    • これは意図していなかったのですが、第三者視点で見てもちゃんと効果があったようで良かったです

一方で課題も生まれています。

  • ユースケースの抽出を見誤ると逆に「認知負荷が高くテスタビリティの低いコード」の温床になり得る
    • ユースケースの粒度や範囲には制約も答えも無く、誤った粒度、責務の過不足、といった状態に陥るリスクを抱えている
    • アプリケーション知識を豊富に持つエンジニアを交えて適切な単位のユースケースを見出していくことが大事
  • ユースケースに対するテストでモックへの依存が増えた
    • 「振る舞い」をテストする上でモックは悪ではないが、一方で「通らないはずのテストが通る」リスクを抱えてしまっている
    • ここはE2Eをちゃんと回してカバーしていきたい
  • 「ドメインやfeatureといったものをどの粒度で切るか」といったアーキテクチャの課題は依然として不明瞭
    • これはまた別の大きな課題なので継続的に見直していく

この手の話は「解決される課題」と「発生する課題」とのトレードオフです。
今回でいうと、前者の「解決される課題」の方を大きくすることができたかなと感じています。

まとめ

というわけで、「フロントエンドアーキテクチャに ”インタラクション層” を導入したら、認知負荷が下がってテスタビリティが上がった」という話を紹介させて頂きました。

今回の取り組みでタイミーのフロントエンドアプリケーションは(少し)改善されました。
ただ全てのコードを見直して “インタラクション層” を導入していくかはまだちょっと分かりません。この考えはどんな状況にもマッチする考え方ではありませんし、そもそもアーキテクチャに銀の弾丸はありませんので…。

とはいえ、前述した状況と同じような状況で悩んでいる方の一助にはなれると思います。もし試す機会があればその結果を共有してもらえると嬉しいです。

今後は「べき論」に囚われず、一方でこの取り組みが地層化しないよう、少しずつ “インタラクション層” の考え方を取り入れていけたらと考えています。

おわりに

タイミーの開発メンバーは「現実を理解しながらも粘り強く理想の形を模索し実現する」人が多く、日々学ぶことが多いです。加えて事業・プロダクトも面白く意義深いですし、これまでのキャリアを振り返っても今が最も刺激的だと感じています。
タイミーの開発に興味を持った方は是非カジュアル面談を。話を聞くだけでも損は無いと思います。

https://product-recruit.timee.co.jp/casual

※1: feature単位でディレクトリを分割し、関連するファイルをまとめる手法を指しています
※2: フレームワークのpageファイルではありません。タイミーのフロントエンドアプリケーションではpageファイルに多くを書かず、Pageコンポーネントに処理を委譲しています

Discussion