💡

フロントエンド開発の単体テストにおけるモックのベストプラクティス

2023/06/10に公開

はじめに

単体テストの考え方/使い方にモックのベストプラクティスが記載してあったので、フロントエンド開発の単体テストに取り入れた場合の有効性を共有したいと思います。
※本稿ではReactを取り上げていますが、その他ライブラリ・フレームワークでも活用できる内容です!

良い単体テストを構成する4本の柱

前提に良い単体テストを構成するものとして、下記の4本の柱を紹介します。
⭐️:モックのベストプラクティスに関連する柱

退行に対する保護⭐️

ソフトウェアで発生する退行とはバグのことであり、何らかの変更(新規機能追加・削除等)を加えた後に既存の機能が意図した挙動でなくなることを指す。その大綱から保護される仕組みを作り出すことを重視した観点。

リファクタリングへの耐性⭐️

テストが失敗することなく、どのくらいのプロダクションコードへのリファクタリングが行えるか。プロダクションコードを変更する度にテストが失敗するとテストへの信頼性が失われていく。信頼性が失われていくとテストが失敗することに慣れていきテスト結果を重要視しなくなる。結果として、問題のあるコードを本番環境に持ち込まれてしまう恐れがある。

迅速なフィードバック

テストの実行時間。速やかにテストが行えるようになるとフィードバックを得てから改善するまでの時間が短くなる。逆にフィードバックが遅いとバグがプロダクションコードに存在する時間が長くなったり、テストの実行回数が減ってしまう。

保守のしやすさ

変更容易性やコード量が少ない、テストケースのサイズが小さい等のテストコード自体の可読性・保守性の観点。

モックのベストプラクティス

モックに置き換える対象は管理下にない依存、つまり外部アプリケーションから観察可能な依存[1]だけになります。もし、これ以外の異なる依存をモックに置き換えてしまうと・・・

  • 退行に対する保護ができず、バグが生じやすいテスト
  • リファクタリング耐性が失われ壊れやすいテスト

といった保守性の低いテストを実装することになってしまいます。

コードで見る具体例

具体的にコード例を見て解説したいと思います。
今回のケースは、/tablesにGETリクエストを送り、下記のレスポンスボディをUI上に表示するケースとします。

tables: [
  {id: 0, name: "テーブル0"},
  {id: 1, name: "テーブル1"},
]

対象コード

ディレクトリ構成

TableList/
├─ TableList.tsx
├─ hooks/
│  └─ useTables.ts
├─ __tests__
   └─ TableList.test.tsx
useTables.ts
import { useCallback, useState, useEffect } from "react";

interface Table {
  id: number;
  name: string;
}

export const useTables = () => {
  const [tables, setTables] = useState<Table[]>([]);

  const fetchTables = useCallback(async () => {
    const res = await fetch("/tables");
    const data: Table[] = await res.json();
    setTables(data);
  }, []);

  useEffect(() => {
    fetchTables();
  }, [fetchTables]);

  return { tables };
};
TableList.tsx
import { memo } from 'react';
import { useTables } from './hooks/useTables';

export const TableList: React.FC = memo(() => {
  const { tables } = useTables();

  return (
    <ul>
      {tables.map((table) => (
        <li key={table.id}>{table.name}</li>
      ))}
    </ul>
  );
});

アンチパターン🙅‍♂️

Table.test.tsx
import { render, screen } from "@testing-library/react";
import { TableList } from "../tableList";

// useTablesをモックしている
jest.mock("../hooks/useTables", () => ({
  useTables: jest.fn().mockReturnValue({
    tables: [
      { id: 0, name: "テーブル0" },
      { id: 1, name: "テーブル1" },
    ],
  }),
}));

describe("TableListコンポーネントのテスト", () => {
  it("テーブル名のリストが表示される", async () => {
    render(<TableList />);

    const listitems = await screen.findAllByRole("listitem");
    expect(listitems).toHaveLength(2);
  });
});

なぜアンチパターン?
理由は2つあります。

1️⃣ 退行に対する保護ができていない

useTables.ts
const fetchTables = useCallback(async () => {
  const res = await fetch("/tables");
  const data: Table[] = await res.json();
  const filteredData = data.filter((item) => item.id > 0); // 仕様変更
  setTables(filteredData);
}, []);

上記のようにuseTablesfetchTablesが「idが0より大きい数のみにフィルターして読み込む」という仕様に変更された場合、本来であればTableListコンポーネント(TableList.test.tsx)のUTは失敗するのが期待されるはずです。
しかし、useTablesをモックしているためTableListコンポーネントの単体テストが成功するようになってしまいます。
= 何らかの変更(新規機能追加・削除等)を加えた後に既存の機能が意図した挙動でなくなるため退行に対する保護ができていないのがわかります。

2️⃣ リファクタリングへの耐性が低い

useTablesuseTableListtablestableListと命名変更した場合でもTableListコンポーネントの挙動は変わらず正しい振る舞いをしています。
しかし、useTablesをモックしているためTableListコンポーネントの単体テストは失敗してしまいます。
= プロダクションコードを変更する度にテストが失敗するため、リファクタリングへの耐性が低いことがわかります。

デザインパターン🙆‍♂️

Table.test.tsx
import { render, screen } from "@testing-library/react";
import { TableList } from "../tableList";

describe("TableListコンポーネントのテスト", () => {
  it("テーブル名のリストが表示される", async () => {
    // fetchをモックしている
    const mockJson = jest.fn().mockReturnValue([
      { id: 0, name: "テーブル0" },
      { id: 1, name: "テーブル1" },
    ]);
    const mockFetch = jest.fn().mockReturnValue({ json: mockJson });
    global.fetch = mockFetch;

    render(<TableList />);

    const listitems = await screen.findAllByRole("listitem");
    expect(listitems).toHaveLength(2);
  });
});

退行に対する保護ができている

useTablesの仕様・ロジックが変わりTableListコンポーネントへ影響がでた場合テストが失敗するようになっている。
= 退行に対する保護ができているため、品質の高いプロダクションコードを維持することができている。

リファクタリングへの耐性が強い

useTablesuseTableListtablestableListと命名変更した場合でもTableListコンポーネントの単体テストは成功している。
= リファクタリングへの耐性が強いため単体テストの信頼性が高い状態を維持できている。

最後に

最後までお読みいただきありがとうございました!
良い単体テストを構築するためには、良い単体テストを構成する4本の柱モックのベストプラクティスを組み合わせて適切なテスト戦略を作り出すことが重要です。そして、テストを継続的に実行し、フィードバックを得ることでプロダクトの品質向上に寄与することができます。
以上が、フロントエンド開発における単体テストの有効性に関する共有でした。良いテストを通じて、より信頼性の高いソフトウェアを作り上げていきましょう🔥
単体テストのAAAパターンについても取り上げているのでよかったら見てください〜👀
https://zenn.dev/brunchmade/articles/3ad9287863cc1a

脚注
  1. 公開されてる依存。クラスでいうpublicなメソッドや状態のこと。 ↩︎

Discussion