3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

"単体テストの考え方/使い方"をReact設計で試してみた

3
Last updated at Posted at 2026-01-31

はじめに

今まで単体テストはソースコードが仕様通りになっているか確認するためだけのものだと思っていましたが、以下書籍を読んでから、プロジェクトを成長させるには必要不可欠なものと認識が変わりました。

この書籍はバックエンド中心に語られていますが、Reactのようなフロントエンド側でも重要な考え方だと思います。そこでこの書籍で書かれている単体テスト観点から設計を見直す作業を、Reactアプリでやってみました。

解釈や方法について間違っている点があるかもしれません。
その際はご指摘いただけますと幸いです。

書籍の内容

書籍の内容については以下のQiita記事にて綺麗に整理されていますので、詳細が気になる方はご確認ください。
https://qiita.com/mokio/items/95e962c59a142978bcb2

一部抜粋しますと、次のようなことが記載されています。

  • 単体テストの目的はソフトウェア開発プロジェクトの成長を持続可能なものにすること
  • テストスイートの質を良くする方法
  • テスト対象となるプロダクション・コードと共にテスト・スイートをリファクタリングするにはどうするのか?
  • どのように統合テストを使って、システムの全体的なふるまいを検証するのか?
  • 単体テストにおけるアンチ・パターンをどのように識別し、そして回避するのか?

書籍の解釈

次からは書籍を読んで私なりに理解した、単体テストからReactアプリの設計を見直すための解釈です。

単体テストの目的

単体テストはソースコードが仕様通りに動作することを確認しますが、それ自体が目的ではありません。

単体テストの目的

持続的な成長ができるプロジェクトにすること

つまり、以下のような流れを作ることです。
単体テストがうまく機能している
 ↓
バグを検知しやすくなる
 ↓
リファクタリングや機能追加が怖くなくなる
 ↓
開発スピードが上がってサービスの品質が良くなる

どんな単体テストをかければ良いのか

書籍では単体テストの良し悪しは自動的に判断することはできないといっています。
(網羅率といった指標はありますが、網羅率自体を目指しても網羅率を高めるためだけの無駄なテストケースが増えてしまい逆効果となる可能性があります。)

目指すべき単体テストは、価値の高い単体テストといっています。

価値の高い単体テストとは?

重要なプロダクションコード(ビジネスロジック)をテスト対象にしている単体テストです。
LT.jpg

逆に価値の低い単体テストとは、次のようなやる意味がほとんど無いプロダクションコードを対象にしたテストです。

単体テストの作りやすさ

注力すべきテストの対象はドメインロジックであることがわかりました。
「よし、じゃあドメインロジックをテストするぞ!」と意気込んだものの、単体テストは面倒なものです。

単体テストが面倒に感じる理由は、プロダクションコードの設計が良くない可能性があります。

単体テストの作りやすさというのは、対象のプロダクションコードの書きっぷりに依存します。
単体テストが作りにくい時、プロダクションコードは次のような状態になっていることが多いです。

  • 条件分岐が多い
  • 依存関係が多い

つまり密結合になっているというわけです。一つの振る舞いを単体テストしたいだけなのに、条件分岐や依存関係が多いせいで、テストパターンの組み合わせが多くなったり、事前の設定など、無駄に考慮しないといけないことが多くなります。

密結合の状態は、1つの処理をいじれば他のところに影響が出る可能性が高く、リファクタリングや機能追加するのも一苦労です。

なので単体テストがしにくい → 成長しにくい設計になっているということがわかります。

LT.jpg

単体テストが作りやすければ成長しやすい設計?

答えはNoです。
単体テストが作りやすいということは、プロダクションコードが疎結合になっているということです。これはあくまで異なるコード同士が分離されているだけなので、リファクタリングや機能追加がしやすい保証にはなりません。

例えば、似たような処理が複数のソースファイルで別々にハードコーディングされている場合などです。処理が変更になった場合の修正漏れが発生する可能性があります。

単体テストが作りやすい状態は、成長しやすいプロジェクトの最低条件である

成長しやすいプロジェクトの単体テストとは

これらのことからビジネスロジックに関する単体テストが書きやすい状態であれば、持続的な成長がしたいプロジェクトにおいて理想的であると言えます。

設計を見直すためのアプローチ

価値の高いテストケースを作りやすくするためには、複雑なソースコードを改善する必要があります。
改善方法のアプローチとして、コードを分割していきます。

このアプローチは書籍と以下の点で異なっています!

  • 書籍では「ドメインロジック」「アルゴリズム」「コントローラ」の3つではなく、「ドメインロジック/アルゴリズム」「コントローラ」の2つと記載しています
  • 3つにしたのはこの後説明するReactにおけるアプローチ場合、ドメインロジックとアルゴリズムを分けた方が検討しやすいためです

LT_20260131_bk_01.jpeg

ソースコードを役割ごとに分けた場合、大きく次の3つに分けられます。

ドメインロジック アルゴリズム コントローラ
サービスにとって重要な処理 ドメインロジック以外の処理 処理と外部プロセスの中継役
例:合計金額の計算 例:消費税の計算 例:ドメインロジックの結果をDBへ伝える

複雑なコードは1つのソースファイルに複数の役割のコードがまとまっている場合が多いです。そのため複雑なコードを役割ごとに分割していきます。

React設計への活かし方を考えてみた

書籍ではバックエンドメインで解説がされていますが、書籍の考え方をReactへ活かした場合を自分なりに考えてみました。

この内容は個人的な見解となりますので、間違っている点があるかもしれません。その場合はぜひご指摘いただけますと幸いです。

サンプルアプリ(ユーザ一覧画面)

複雑なコードのサンプルとしてユーザ一覧画面があるアプリを用意しました。
image.png

機能は以下です。

  • ユーザ情報はname, email, phone, websiteを保持
  • 検索条件に入力したキーワードで表示ユーザをフィルタリングする
    • いずれかの列に値が含まれていればそのユーザを表示する
  • ユーザ一覧上でキーワードに一致する文字列にハイライトをつける

フィルタリングのイメージ例は以下です。
image.png

ユーザ一覧画面のソースコード

複雑になっているユーザ一覧のソースコードをサンプルとして一部表示します。

ユーザ一覧テーブル(一部抜粋)
const BeforeUserTable: FC<Props> = ({ initialUsers }) => {
    const setHighlight = (val: string) => {
        return `${val.replace(keyword,
        `<span style="background:#FFB3BF;">${keyword}</span>`)}`;
    };

    // フィルタリングとハイライトをつける処理
    const filteredUsers: User[] = initialUsers.flatMap((x) => {
        let isHit = false;
        let name = x.name;
        let email = x.email;
        let phone = x.phone;
        let website = x.website;
        if (x.name.includes(keyword)) name = setHighlight(x.name);
        isHit = true;
        if (x.email.includes(keyword)) email = setHighlight(x.email);
        isHit = true;
        if (x.phone.includes(keyword)) phone = setHighlight(x.phone);
        isHit = true;
        if (x.website.includes(keyword)) website = setHighlight(x.website);
        isHit = true;

        if (!isHit) return [];
        return { ...x, name, email, phone, website };
    });
    setUsers(filteredUsers)

    return(<div>{/* 省略 */}</div>)
};

コードだけだと分かりにくいので、ざっくり内容を文字化したものが以下です。
現状はユーザ一覧コンポーネントの中で「フィルタリング処理」「ハイライト処理」「描画処理」全てを実装している状態です。
LT_20260131_bk_01 (1).jpeg

ソースコードの全量はこちらです
import { useEffect, useState, type FC } from "react";
import { User } from "@/domains/User";

interface Props {
  initialUsers: User[];
}

const cols = ["name", "email", "phone", "website"] as const;

const BeforeUserTable: FC<Props> = ({ initialUsers }) => {
  const [users, setUsers] = useState<User[]>([]);
  const [keyword, setKeyword] = useState("");

  useEffect(() => {
    if (keyword === "") {
      setUsers(initialUsers);
      return;
    }
    const setHighlight = (val: string) => {
      return `${val.replace(keyword, `<span style="background:#FFB3BF;">${keyword}</span>`)}`;
    };

    const filteredUsers: User[] = initialUsers.flatMap((x) => {
      let isHit = false;
      let name = x.name;
      let email = x.email;
      let phone = x.phone;
      let website = x.website;
      if (x.name.includes(keyword)) name = setHighlight(x.name);
      isHit = true;
      if (x.email.includes(keyword)) email = setHighlight(x.email);
      isHit = true;
      if (x.phone.includes(keyword)) phone = setHighlight(x.phone);
      isHit = true;
      if (x.website.includes(keyword)) website = setHighlight(x.website);
      isHit = true;

      if (!isHit) return [];
      return { ...x, name, email, phone, website };
    });

    setUsers(filteredUsers);
  }, [keyword]);

  return (
    <div className="max-w-4xl mx-auto">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <h1 className="text-3xl font-bold mb-6 text-gray-600 text-center">
          ユーザ一覧テーブル
        </h1>

        <div className="flex gap-2 mb-6">
          <label
            htmlFor="filterCondition"
            className="flex items-center text-gray-500 text-md"
          >
            検索条件
          </label>
          <input
            id="filterCondition"
            type="text"
            onChange={(e) => setKeyword(e.target.value)}
            value={keyword}
            placeholder="キーワード入力"
            className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-transparent"
          />
        </div>

        <div className="relative overflow-x-auto  shadow-xs rounded-lg border border-gray-300">
          <table className="table-fixed w-full text-sm text-left rtl:text-right text-body">
            <thead className="text-sm text-body bg-gray-100 border-b rounded-lg border-gray-300">
              <tr>
                {cols.map((col) => (
                  <th scope="col" key={col} className="px-6 py-3 font-medium ">
                    {col}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {users.map((user, index) => (
                <tr
                  className={`${index === users.length - 1 ? "" : "border-b border-gray-300"}`}
                  key={user.id}
                >
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.name }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.email }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.phone }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.website }}
                  />
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
};

export default BeforeUserTable;

現状のコードの問題点

現状のソースコードの問題点を改めて確認します。冒頭でお話した通り、複雑なソースコードは単体テストが書きにくくなります。

上記ソースコードを見ると、検証対象の振る舞い(キーワードを入力するとハイライトされたフィルタリング結果が表示される)を検査したい場合、色々なテストパターンの組み合わせを考慮しなければなりません。
LT_20260131_bk_01 (2).jpeg

組み合わせが多ければ単体テストを作る量が増えることに加え、新しい要素(列の追加など)が加わった場合、それも考慮する必要があります。

またテストケースの書き方にも気を付けないとリファクタリングに弱い単体テストとなります。(書籍ではリファクタリングに強い単体テストの書き方の説明もありますが、本記事では触れません。)

考慮すべき要素が多くなることで、単体テストの量が増える

現状コードに対するテストケースのサンプル

以下はvitestというテストライブラリを使った単体テストコードのサンプルです。
現状のコードに対して単体テストを書こうとすると、1パターンであってもこんな感じになるというイメージのためにのせています。ご興味ある方はご覧ください。

現状コードに対するテストケースのサンプル
現状コードに対するテストケースのサンプル(一部
import BeforeUserTable from "@/components/BeforeUserTable";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const user = userEvent.setup();

describe("ユーザ一覧テスト", () => {
  beforeEach(() => {
    render(
      <BeforeUserTable
        initialUsers={[
          {
            id: 1,
            name: "田中太郎",
            email: "tanaka@april.biz",
            phone: "1-770-736-8031 x56442",
            website: "hildegard.org",
          },
          {
            id: 2,
            name: "田中花子",
            email: "tanaka@april.biz",
            phone: "1-770-736-8031 x56442",
            website: "hildegard.org",
          },
        ]}
      />,
    );
  });
  test("フィルタリング機能テスト_name", async () => {
    await waitFor(() => {
      expect(screen.queryByText("ローディング中")).not.toBeInTheDocument();
    });
    const keywordField = await screen.findByLabelText("検索条件");
    await user.type(keywordField, "田中");

    const sut = await screen.findAllByText("田中");
    const table = await screen.findByRole("table");

    await waitFor(() => {
      // レコード値チェック
      expect(table).toHaveTextContent("田中太郎");
      expect(table).toHaveTextContent("田中花子");

      // レコード数チェック
      expect(screen.getAllByRole("row").length - 1).toBe(2);

      // ハイライトチェック
      sut.forEach((x) =>
        expect(x).toHaveAttribute("style", "background:#FFB3BF;"),
      );
    });
  });
});

現状コードの改善案

では複雑なコードを役割ごとに分割していきます。まずは各処理の役割を分類します。
今回の場合は次のキャプチャのように役割を考えました。
LT_20260131_bk_01 (3).jpeg

フィルタリング処理とハイライト処理

フィルタリング処理は言わずもがなですが、ハイライト処理もビジネスロジックとしました。見え方に関する部分なのでアルゴリズムとしても良いかと思ったのですが、この機能はフィルタリング処理とセットになる可能性が高いと思ったので、ビジネスロジックに分類しました。

描画処理

描画はReactコンポーネントの本分なので、アルゴリズムと分類しました。

役割の分け方は人やプロジェクトによって異なると思います。上述の分け方は私個人の考えということをご承知おきください。

リファクタリング(フィルタリング処理とハイライト処理)

まずはフィルタリング処理とハイライト処理をコンポーネントから切り分けていきます。
切り分け方は色々ありますが、今回はUsersクラスという"表示するユーザ情報を保持するクラス"のメソッドにしていきます。

Usersクラス
export class Users {
  initialUsers: User[];

  #setHighlight(val: string, keyword: string) {
    return `${val.replace(keyword, `<span style="background:#FFB3BF;">${keyword}</span>`)}`;
  }

  filterUsers(keyword?: string) {
    if (!keyword) return this.initialUsers;
    return this.initialUsers.filter(
      (x) =>
        x.name.includes(keyword) ||x.email.includes(keyword) ||
        x.phone.includes(keyword) || x.website.includes(keyword),
       );
  }

  filterUsersWithHighlight(keyword?: string) {
    return this.filterUsers(keyword).map((x) => ({
      ...x,
      name: this.#setHighlight(x.name, keyword!),
      email: this.#setHighlight(x.email, keyword!),
      phone: this.#setHighlight(x.phone, keyword!),
      website: this.#setHighlight(x.website, keyword!),
    }));
  }
}

filterUsersメソッド

フィルタリング処理を切り出したメソッドです。引数としてキーワードを受け取り、フィルタリングしたユーザ一覧(配列データ)を返します。

filterUsersWithHighlightメソッド

ハイライト処理を切り出したメソッドです。この処理の中でfilterUsersメソッドを呼び出すようにしています。こうすることで、ハイライトあり無しのフィルタリング処理を選べるようになります。

リファクタリング(描画処理)

ドメインロジックを切り分けたことでコンポーネントがどうなったかをみていきます。

const AfterUserTable: FC<Props> = ({ initialUsers }) => {
  useEffect(() => {
    if (!usersRef.current) return;

    // ハイライトつきのフィルタリングしたユーザ一覧データを受け取るだけ
    // (一行でかけるようになりました)    
    const filteredUsers = 
        usersRef.current.filterUsersWithHighlight(keyword);

    setUsers(filteredUsers);
  }, [keyword]);

    return(<div>{/* 省略 */}</div>)
};

リファクタリング前は長々と見にくい処理がかかれていましたが、一行で済むようになりました。
これでコンポーネントは受け取ったハイライト済みのフィルタリングされたユーザ一覧(配列データ)を、そのまま描画するだけになりました。
つまり、コンポーネントは配列を表示するという責務だけをもつようになりました。

リファクタリング後の単体テスト

複雑なコードを分割した結果、単体テストの書き方がどうなったかを見ていきます。

描画機能に関する単体テスト

まずは描画機能(アルゴリズム)のテストコードのサンプルです。

注目ポイント

  • 描画処理だけに集中できるようになった
  • 最低限のテストとして、ユーザ一覧データ(配列)に1つ以上のデータがあるパターンと、空配列なパターンの2種だけで済むようになった
描画機能に関するテストコード
test("描画_データあり", async () => {
   const keywordField = await screen.findByLabelText("検索条件");
   await user.type(keywordField, "a");
   const rows = screen.getAllByRole("row");

   await waitFor(() => {
     expect(screen.queryByText("田中太郎")).toBeInTheDocument();
     expect(screen.queryByText("田中花子")).toBeInTheDocument();
     expect(rows.length - 1).toBe(2);
   });
 });

 test("描画_データなし", async () => {
   const keywordField = await screen.findByLabelText("検索条件");
   await user.type(keywordField, "a");
   const rows = screen.getAllByRole("row");
   await waitFor(() => {
     expect(rows.length - 1).toBe(0);
   });
 });

コンポーネントは受け取った配列データをそのまま描画しているだけなので、フィルタリングやハイライトが失敗したかどうかは全く気にしません。
あくまで配列データを描画しているかだけを確認できればOKです。
上述のサンプルでは配列数が想定通りかだけを検証していますが、テスト用のダミー配列と戻り値をそのまま比較すればより確実です。

フィルタリング機能に関する単体テスト

次はUsersクラスのfilterUsersメソッドとして切り出したフィルタリング機能に関するテストコードのサンプルです。
こちらも描画機能のテストコードと同様に自分の処理だけに関してテストコードが書けるようになっています。

フィルタリング機能に関するテストコード
describe("ドメインロジック", () => {
  const users = new Users([
    {
      id: 1,
      name: "田中太郎",
      email: "tanakataro@april.biz",
      phone: "123-456-789",
      website: "taro.org",
    },
    {
      id: 2,
      name: "田中花子",
      email: "tanakahanako@april.biz",
      phone: "123-456-780",
      website: "hanako.org",
    },
  ]);

  test("フィルタリング機能_name列", () => {
    const sut = users.filterUsers("田中");
    expect(sut.length).toBe(2);
  });

  // フィルタリング機能_email列
  // etc….
});

この処理は"ドメインロジック"なので複数のテストパターンで単体テストをすべきです。テストが書きやすくなったことや、描画機能に関するテストのリソースが浮いた分、ドメインロジックのテストを重点的にやっていきましょう。

リファクタリング方法の改善

@honey32 さんより、Classとして切り出すことで不要なuseEffectなどが存在しコードを複雑にしているとご助言をいただきました。
改めてuseMemoを使えば、ご助言通り、かなりすっきりした記述になりました!
以下ソースコードになります。

AfterUserTable
import { User } from "@/domains/User";
import { useMemo, useState, type FC } from "react";
interface Props {
  initialUsers: User[];
}
const cols = ["name", "email", "phone", "website"] as const;

const setHighlight = (val: string, keyword: string) => {
  return `${val.replace(keyword, `<span style="background:#FFB3BF;">${keyword}</span>`)}`;
};

const AfterUserTable: FC<Props> = ({ initialUsers }) => {
  const [keyword, setKeyword] = useState("");

  const filteredUsersWithHighlight = useMemo(() => {
    if (!keyword) return initialUsers;
    const result = initialUsers
      .filter(
        (x) =>
          x.name.includes(keyword) ||
          x.email.includes(keyword) ||
          x.phone.includes(keyword) ||
          x.website.includes(keyword),
      )
      .map((x) => ({
        ...x,
        name: setHighlight(x.name, keyword!),
        email: setHighlight(x.email, keyword!),
        phone: setHighlight(x.phone, keyword!),
        website: setHighlight(x.website, keyword!),
      }));

    return result;
  }, [keyword]);

  return (
    <div className="max-w-4xl mx-auto">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <h1 className="text-3xl font-bold mb-6 text-gray-600 text-center">
          ユーザ一覧テーブル
        </h1>

        <div className="flex gap-2 mb-6">
          <label
            htmlFor="filterCondition"
            className="flex items-center text-gray-500 text-md"
          >
            検索条件
          </label>
          <input
            id="filterCondition"
            type="text"
            onChange={(e) => setKeyword(e.target.value)}
            value={keyword}
            placeholder="キーワード入力"
            className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-transparent"
          />
        </div>

        <div className="relative overflow-x-auto  shadow-xs rounded-lg border border-gray-300">
          <table className="table-fixed w-full text-sm text-left rtl:text-right text-body">
            <thead className="text-sm text-body bg-gray-100 border-b rounded-lg border-gray-300">
              <tr>
                {cols.map((col) => (
                  <th scope="col" key={col} className="px-6 py-3 font-medium ">
                    {col}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {filteredUsersWithHighlight.map((user, index) => (
                <tr
                  className={`bg-neutral-primary ${index === filteredUsersWithHighlight.length - 1 ? "" : "border-b border-gray-300"} `}
                  key={user.id}
                >
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.name }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.email }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.phone }}
                  />
                  <td
                    className="px-6 py-4"
                    dangerouslySetInnerHTML={{ __html: user.website }}
                  />
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
};

export default AfterUserTable;

おわりに

単体テスト作成の視点から成長しやすい設計を意識するための考え方について学ばせていただきました。

単体テストからフロントエンドのプロダクションコードをリファクタリングしていくアプローチですが、検討する際はDDDの分野にも通じるところがあると思いました。
(DDDの理解が足りていないため、今回の記事ではDDDのことは触れていません)

単体テストをプロダクションコードの追従と考えていた自分にとって、この書籍を読んだおかげでどこまで単体テストをすべきかという目安を少しつかめたと感じました。

今回ご紹介しきれなかった内容の中には、リファクタリングに強い単体テストの書き方や、モックのベストプラクティスなども明記されています。

単体テストではなく統合テストについても説明されていますので、テストの書き方やどこまでやれば十分なのか曖昧な方は一見の価値ありです。

参考

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼

3
0
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?