⚙️

テスト容易性と再利用性を高めるuseMemoとビジネスロジックの分離戦略

2025/01/12に公開

Reactで開発を進める中で、useMemo はパフォーマンスを最適化する便利なフックですが、使い方によってテストの容易性やロジックの再利用性に意外な影響を与えると感じています。

たとえば、useMemo 内にビジネスロジックを直接記述する場合、テストはコンポーネント全体を対象にする必要があります。
一方、ビジネスロジックをコンポーネント外や別ファイルに切り出すと、ビジネスロジック単体をユニットテストで簡潔に検証でき、テストの容易性が向上します。
また、ビジネスロジックを分離することで、再利用性も向上します。

こうした設計の違いが、プロジェクト全体のテスト方針や開発体験にどのような影響を与えるのかを理解することは、効率的な開発のために非常に重要です。

そこでこの記事では、useMemo を用いたビジネスロジックの扱い方を4つのパターンで比較し、それぞれのメリット・デメリットや、テストへの影響を解説します。

本題に入る前の準備

本記事では具体的な例を用いて「テスト容易性と再利用性を高めるuseMemoとビジネスロジックの分離戦略」について解説する前に、まずはシンプルなアプリケーション環境を用意します。
今回の題材は「ユーザーのロールに応じた権限管理」です。以下の 3 つを準備することで、API の呼び出し → データ取得 → 権限表示 という流れを実際に確認できるようにします。

開発環境

記事執筆時に試した環境は以下になり、Next.jsのルーティングシステムはApp Routerを採用しています。
テストにはJestを使用しています。

ライブラリ バージョン
Next.js 14.1.4
React 18.3.1
Jest 29.7.0

Next.js と Jest のセットアップについては、公式ドキュメントをご参照ください。
https://nextjs.org/docs/app/building-your-application/testing/jest

準備するコード

※ 本記事のサンプルコードは実演用のため、細かい部分には目を瞑っていただけますと幸いです。

1. ユーザー情報を返す API ルート
この API はユーザー情報をランダムに返します。
本記事のサンプルで、権限管理の動作を確認するための基礎データとなります。

src/app/api/users/route.ts
import { NextResponse } from "next/server";

// ユーザー情報
const USERS = [
  { id: 1, name: "ユーザー1", role: "admin" },
  { id: 2, name: "ユーザー2", role: "editor" },
  { id: 3, name: "ユーザー3", role: "viewer" },
];

export function GET(): NextResponse {
  // ランダムでユーザー情報を返すモック処理
  const randomUser = USERS[Math.floor(Math.random() * USERS.length)];

  return NextResponse.json(randomUser);
}

2. ユーザー情報を取得
このコンポーネントは API を呼び出し、ランダムに返されたユーザー情報を取得して子コンポーネントに渡します。

src/app/page.tsx
import { ChildComponent } from "./components/ChildComponent";

export async function Parent() {
  // ユーザー情報をテスト用データで取得
  const user = await fetch("http://localhost:3000/api/users").then((res) => res.json());
  return <ChildComponent user={user} />;
}

3. クライアントコンポーネントで useMemo を使い、ロールに応じて権限を表示
取得したユーザーのロールに基づいて、対応する権限を表示するクライアントサイドコンポーネントです。

src/types.ts
/**
 * ユーザーのロール定義
 * - 管理者(admin)
 * - 編集者(editor)
 * - 閲覧者(viewer)
 */
export type Role = "admin" | "editor" | "viewer";

export type User = {
  id: number;
  name: string;
  role: Role;
};
src/app/components/ChildComponent.tsx
"use client";

import { useMemo } from "react";
import type { User } from "@/types";

type Props = {
  user: User;
};

export const ChildComponent = ({ user }: Props) => {
  // ロールごとの権限を返す
  const permissions = useMemo(() => {
    switch (user.role) {
      case "admin":
        return ["作成", "編集", "削除", "閲覧"];
      case "editor":
        return ["編集", "閲覧"];
      case "viewer":
        return ["閲覧"];
    }
  }, [user.role]);
  return (
    <div>
      <h2>ユーザー名: {user.name}</h2>
      <h3>ロール: {user.role}</h3>
      <h3>利用可能な権限</h3>
      <ul>
        {permissions.map((permission) => (
          <li key={permission}>{permission}</li>
        ))}
      </ul>
    </div>
  );
};

具体的な例の紹介

具体的な例として、「useMemoとビジネスロジックの配置方法がテスト容易性と再利用性にどのように影響を与えるか」 を解説していきます。
今回は、以下の4つのパターンについて比較・解説を行います。

  1. useMemo内にビジネスロジックを直書き
  2. ビジネスロジックをコンポーネント内に関数として分離
  3. ビジネスロジックをコンポーネント外に分離
  4. ビジネスロジックを別ファイルに分離

これから、各パターンのコンポーネントの実装例、テストコード、そしてメリット・デメリットについて順番に解説していきます。

1. useMemo内にビジネスロジックを直書き

コンポーネントの実装

export const ChildComponent = ({ user }: { user: User }) => {
  // useMemo内にビジネスロジックを直接書いている
  const permissions = useMemo(() => {
    switch (user.role) {
      case "admin":
        return ["作成", "編集", "削除", "閲覧"];
      case "editor":
        return ["編集", "閲覧"];
      case "viewer":
        return ["閲覧"];
    }
  }, [user.role]);

  return (
    <div>
      {/* UIの詳細なデザイン部分は省略 */}
    </div>
  );
};

テストコード

import { render } from "@testing-library/react";
import type { User } from "@/types";

import { ChildComponent } from "@/app/components/ChildComponent";

test("管理者ロールの権限を表示する", () => {
  const user: User = { id: 1, name: "ユーザー1", role: "admin" };
  const { getByText } = render(<ChildComponent user={user} />);
  expect(getByText("作成")).toBeInTheDocument();
  expect(getByText("編集")).toBeInTheDocument();
  expect(getByText("削除")).toBeInTheDocument();
  expect(getByText("閲覧")).toBeInTheDocument();
});

// 他のロールのテストも同様に記述

このアプローチでは、ビジネスロジックがuseMemoの中に直接書かれており、ユーザーのロールに基づいた権限を計算しています。
テストコードはコンポーネント全体をレンダリングして検証しています。


メリット

  • 開発速度の向上
    • 実装がシンプルで、追加の関数やファイルを作る手間が省ける。
    • 開発速度を重視する場面や、一時的な小規模プロジェクト(例: プロトタイプやMVP)では初期開発を迅速に進められるため効果的。

デメリット

  • テストの容易性が低い
    • ビジネスロジックが useMemo 内に埋め込まれているため、単体テストで検証することが難しい。
    • コンポーネント全体をレンダリングしないと動作確認ができない。
  • 再利用性が低い
    • 同じビジネスロジックを他のコンポーネントで利用したい場合、ビジネスロジックの重複が発生する。
  • 可読性の低下
    • ロジックの意図が関数名などで明示されていないため、読み手が意図を理解しにくい。
    • 特に複雑なビジネスロジックの場合、チームメンバーがコードの意図を把握するのに時間がかかる可能性がある。

このように、useMemo内にビジネスロジックを直書きする方法はシンプルですが、テストの容易性や再利用性、可読性に課題があります。
次に、ビジネスロジックをコンポーネント内で関数として分離する場合を見てみましょう。

2. ビジネスロジックをコンポーネント内に関数として分離

コンポーネントの実装

export const ChildComponent = ({ user }: Props) => {
  const getPermissions = useCallback((role: Role) => {
    switch (role) {
      case "admin":
        return ["作成", "編集", "削除", "閲覧"];
      case "editor":
        return ["編集", "閲覧"];
      case "viewer":
        return ["閲覧"];
    }
  }, []);

  const permissions = useMemo(() => getPermissions(user.role), [user.role, getPermissions]);

  return (
    <div>
      {/* UIの詳細なデザイン部分は省略 */}
    </div>
  );
};

テストコード

import { render } from "@testing-library/react";
import type { User } from "@/types";

import { ChildComponent } from "@/app/components/ChildComponent";

test("管理者ロールの権限を表示する", () => {
  const user: User = { id: 1, name: "ユーザー1", role: "admin" };
  const { getByText } = render(<ChildComponent user={user} />);
  expect(getByText("作成")).toBeInTheDocument();
  expect(getByText("編集")).toBeInTheDocument();
  expect(getByText("削除")).toBeInTheDocument();
  expect(getByText("閲覧")).toBeInTheDocument();
});

// 他のロールのテストも同様に記述

このアプローチでは、ビジネスロジック(権限の計算)をコンポーネント内のgetPermissionsという関数として分離しています。
テストコードはコンポーネント全体をレンダリングして検証しており、getPermissions関数が正しく動作するかを確認しています。


メリット

  • ビジネスロジックの再利用性の向上
    • 他のコンポーネントで同じビジネスロジックが必要になった場合、getPermissions をコンポーネント外に切り出しやすい。
  • コードの可読性の向上
    • getPermissions という名前の関数でビジネスロジックを説明できるため、コードの意図がわかりやすい。
  • 柔軟性の向上
    • ビジネスロジックが関数として分離されているため、必要に応じてロジックを追加・変更しやすい。

デメリット

  • テスト対象がコンポーネント全体に限定される
    • getPermissions のテストを行うには、useMemo内にビジネスロジックを直書きした場合と同様に、コンポーネント全体をレンダリングする必要があります。
    • 関数単体を直接テストするには、コンポーネントから分離し、独立したユニットテストとして扱えるようにする必要がある。

このように、ビジネスロジックをコンポーネント内に関数として分離する方法は、再利用性や可読性を一定程度向上させる一方で、テストの容易性に課題が残ります。

次に、ビジネスロジックをコンポーネント外に分離し、よりユニットテストが行いやすくなる設計を見ていきましょう。

3. ビジネスロジックをコンポーネント外に分離

コンポーネントの実装

export const getPermissions = (role: Role) => {
  switch (role) {
    case "admin":
      return ["作成", "編集", "削除", "閲覧"];
    case "editor":
      return ["編集", "閲覧"];
    case "viewer":
      return ["閲覧"];
  }
};

export const ChildComponent = ({ user }: Props) => {
  const permissions = useMemo(() => getPermissions(user.role), [user.role]);

  return (
    <div>
      {/* UIの詳細なデザイン部分は省略 */}
    </div>
  );
};

テストコード

import { getPermissions } from "@/app/components/ChildComponent";

describe("getPermissions関数のテスト", () => {
  test("管理者ロールの場合の権限を返す", () => {
    expect(getPermissions("admin")).toEqual(["作成", "編集", "削除", "閲覧"]);
  });

  // 他のロールのテストも同様に記述
});

メリット

  • テストの容易性の向上
    • getPermissions がコンポーネント外に分離されたことで、直接ユニットテストが可能になり、ビジネスロジック部分だけを検証できる。
  • 再利用性の向上
    • 他のコンポーネントや関数で getPermissions を簡単に利用可能。
    • コンポーネント外に切り出しておくことで、共通化の判断が容易になり、コード全体の保守性が向上する。
  • 保守性の向上
    • 要件変更があった場合でも、コンポーネントを修正せずにビジネスロジックだけを更新できる。

デメリット

  • 過剰設計の可能性
    • ビジネスロジックが単純で、コンポーネント内でしか使わない場合には、わざわざ分離する必要がないケースもある。
    • 分離により構造が複雑化し、小規模なプロジェクトでは逆に開発スピードが低下する可能性がある。

このように、ビジネスロジックをコンポーネント外に分離する方法は、テストの容易性や再利用性、保守性の面で優れています。
しかし、大規模なプロジェクトでは、複数のコンポーネントで共通のロジックを使い回す必要がある場合も少なくありません。

そこで、次に「ビジネスロジックを別ファイルに分離」する方法を見ていきます。

4. ビジネスロジックを別ファイルに分離

ビジネスロジック

export const getPermissions = (role: Role) => {
    switch (role) {
      case "admin":
        return ["作成", "編集", "削除", "閲覧"];
      case "editor":
        return ["編集", "閲覧"];
      case "viewer":
        return ["閲覧"];
    }
};

コンポーネントの実装

import { getPermissions } from "@/utils/getPermissions";

export const ChildComponent = ({ user }: Props) => {
  const permissions = useMemo(() => getPermissions(user.role), [user.role, getPermission]);

  return (
    <div>
      {/* UIの詳細なデザイン部分は省略 */}
    </div>
  );
};

テストコード

import { getPermissions } from "@/utils/getPermissions";
import type { User } from "@/types";

describe("getPermissions関数のテスト", () => {
  test("管理者ロールの場合の権限を返す", () => {
    const user: User = { id: 1, name: "ユーザー1", role: "admin" };
    expect(getPermissions(user.role)).toEqual(["作成", "編集", "削除", "閲覧"]);
  });

  // 他のロールのテストも同様に記述
});

メリット

  • テストの容易性がさらに向上
    • 3と同様にビジネスロジック単体をユニットテストできる。
    • フレームワークに依存しない純粋関数として定義されているため、React 以外の環境でもそのまま利用可能。
  • 再利用性のさらなる向上
    • 別ファイル化し、React の依存を取り除くことで、他のフレームワークでも流用可能。
    • 異なるプロジェクトやチーム間での共有がしやすくなる。
  • 保守性の向上
    • ビジネスロジックの変更が発生した場合、該当ファイルのみを修正すればよく、影響範囲が限定される。
    • 大規模プロジェクトにおいては、分離されたファイル単位での変更が管理しやすい。
  • コンポーネントの責務が明確
    • コンポーネントはUIの実装に専念できるため、ビジネスロジックとUIの役割分担が明確になる。

デメリット

  • 構造の複雑化
    • ビジネスロジックを別ファイルに分離することで、関連するロジックが複数の場所に分散し、全体のつながりを把握しにくくなる可能性がある。
  • 依存関係が増加するリスク
    • フックやビジネスロジックが複数のコンポーネントで共有されるようになるため、変更時に影響範囲が広がるリスクがある。
  • 初期実装の負担
    • 別ファイル化やフックの実装には手間がかかるため、短期間でのプロトタイプ作成や一時的な用途には向かない場合がある。

このアプローチは再利用性と保守性に優れる一方で、構造の複雑化という課題もあります。
大規模プロジェクトや共有ロジックが必要な場面では特に有効ですが、小規模な開発では過剰な可能性もあります。
また、フレームワークに依存しない純粋関数として実装できるため、異なる環境での再利用性が高いのも特徴です。

次に、これら4つのパターンの比較を行い、どのようなシーンでどのパターンを選択するべきかについてまとめます。

まとめ

本記事では、ビジネスロジックの分離がテスト容易性や再利用性に与える影響について、以下の4つのパターンで解説しました。

  1. useMemo内にビジネスロジックを直書き
    • シンプルで実装が手軽な一方、再利用性や保守性が低く、テストがしにくい方法です。
  2. ビジネスロジックをコンポーネント内に関数として分離
    • 再利用性が少し向上しますが、テスト対象は依然としてコンポーネント全体に依存しています。
  3. ビジネスロジックをコンポーネント外に分離
    • テスト容易性や保守性が大幅に向上し、他のコンポーネントでの再利用も容易になります。
  4. ビジネスロジックを別ファイルに分離
    • 再利用性、保守性、テスト容易性がさらに向上し、大規模プロジェクトや複数のチームでの開発に最適な方法です。
    • Reactなどのフレームワークに依存しない設計のため、ロジックをNPMパッケージとして共有することもでき、複数のプロジェクト間で一貫性を持たせつつ、効率的に再利用することが可能です。

どの方法を選ぶべきか?

小規模プロジェクト(スピードを重視する場合)

  • 開発スピードが求められる場面では、1や2の方法がシンプルで効果的です。
  • プロトタイプやMVPのように、短期間で成果を出す必要があるプロジェクトに適しています。

中規模以上のプロジェクト(保守性と再利用性が重要な場合)

  • 長期間の運用やロジックの再利用性を重視する場合は、3のコンポーネント外への分離や、4の別ファイルへの分離が有効です。
  • ビジネスロジックを分離することで、ユニットテストが簡単に行えるため、テストの効率も大幅に向上します。
  • 特に4の方法は、フレームワークに依存しない純粋関数として定義することで、NPMパッケージ化して他プロジェクトやチーム間で共有することも可能です。
    • プロジェクトの拡張性や移植性を高め、大規模なチーム開発に対応できます。

おわりに

今回ご紹介した方法を参考に、プロジェクト規模や目的に応じて最適な方法を選択してみてください。
設計やテスト戦略について深く考えることは、プロジェクトの成功と効率的な開発の鍵となります。
ぜひ実際のプロジェクトで試しながら、最適な設計を見つけてみてください!

Discussion