テストの質を高める:依存性注入(DI)vs Jest.mock/spyOn

テストの質を高める:依存性注入(DI)vs Jest.mock/spyOn

はじめに

ソフトウェア開発において、テストは品質を保証するための重要な手段です。特に API エンドポイントのテストでは、外部依存(データベース、外部 API、ファイルシステムなど)をどのように扱うかが大きな課題となります。これらの外部依存をそのまま使用すると、テストが不安定になったり、実行時間が長くなったりする問題が発生します。

そこで登場するのが「モック(Mock)」です。モックとは、本物の実装の代わりに使用する偽の実装で、テスト対象のコードを外部依存から切り離すことができます。JavaScript のテストフレームワークである Jest では、jest.mockjest.spyOnといった関数を使ってモックを作成できます。

しかし、これらの従来のモック手法には問題点もあります。本記事では、従来のモック手法と依存性注入(DI)パターンを比較し、テストの質を高めるための最適な方法を探ります。

従来のモック手法:jest.mock と jest.spyOn

jest.mock とは

jest.mockは、モジュール全体をモック化する関数です。例えば、以下のようなコードで GitHub クライアントをモック化できます。

// GitHubクライアントをモック化
jest.mock("../shared/github-client", () => ({
  fetchPullRequestDiff: jest.fn().mockResolvedValue("mocked diff"),
  fetchCommitsDiff: jest.fn().mockResolvedValue("mocked diff"),
}));

// テスト対象の関数をインポート
import { fetchDiff } from "../services/github";

test("fetchDiffがGitHubクライアントを呼び出す", async () => {
  const params = { owner: "testorg", repo: "testrepo", pull_number: 123 };
  const result = await fetchDiff(params);

  // モック関数が呼び出されたことを検証
  expect(
    require("../shared/github-client").fetchPullRequestDiff
  ).toHaveBeenCalledWith("testorg", "testrepo", 123, expect.any(String));
  expect(result).toBe("mocked diff");
});

jest.spyOn とは

jest.spyOnは、オブジェクトのメソッドをスパイ(監視)し、必要に応じてモック実装に置き換える関数です。

import * as githubClient from "../shared/github-client";

test("fetchDiffがGitHubクライアントを呼び出す", async () => {
  // メソッドをスパイしてモック実装に置き換える
  const spy = jest
    .spyOn(githubClient, "fetchPullRequestDiff")
    .mockResolvedValue("mocked diff");

  const params = { owner: "testorg", repo: "testrepo", pull_number: 123 };
  const result = await fetchDiff(params);

  // スパイが呼び出されたことを検証
  expect(spy).toHaveBeenCalledWith(
    "testorg",
    "testrepo",
    123,
    expect.any(String)
  );
  expect(result).toBe("mocked diff");

  // スパイをリストア
  spy.mockRestore();
});

従来のモック手法の問題点

これらの手法は広く使われていますが、以下のような問題点があります:

1. グローバルな影響

jest.mockはモジュールキャッシュレベルで動作するため、テストファイル全体に影響します。これにより、あるテストケースのモック設定が他のテストケースに漏れ出す可能性があります。

// テストファイルの先頭でモック化
jest.mock("../shared/github-client");

// テストケース1
test("テストケース1", () => {
  // このモック設定はテストケース2にも影響する
  require("../shared/github-client").fetchPullRequestDiff.mockResolvedValue(
    "mocked diff 1"
  );
  // ...テストコード...
});

// テストケース2
test("テストケース2", () => {
  // テストケース1のモック設定が残っている可能性がある
  // ...テストコード...
});

2. テスト間の干渉

jest.spyOnを使用する場合、各テスト後にmockRestore()を呼び出して元の実装に戻す必要があります。これを忘れると、テスト間で干渉が発生します。

test("テストケース1", () => {
  const spy = jest
    .spyOn(githubClient, "fetchPullRequestDiff")
    .mockResolvedValue("mocked diff");
  // ...テストコード...
  // mockRestore()を忘れると...
});

test("テストケース2", () => {
  // 前のテストのモック設定が残っている!
  // ...テストコード...
});

3. 実装の詳細への依存

jest.mockjest.spyOnは、モジュールのインポートパスや内部実装の詳細に依存します。コードリファクタリングによってこれらが変更されると、テストが壊れる可能性があります。

// 現在のインポート
import { githubClient } from "../shared/github-client";

// リファクタリング後のインポート
import { githubClient } from "../shared/clients/github-client";

// テストコードは更新が必要
jest.mock("../shared/clients/github-client"); // パスが変わった!

4. 複雑なモック設定

複数の依存関係を持つコンポーネントをテストする場合、モック設定が複雑になりがちです。特に、モックの振る舞いを動的に変更する必要がある場合は、コードが煩雑になります。

// 複雑なモック設定の例
jest.mock("../shared/github-client", () => ({
  fetchPullRequestDiff: jest
    .fn()
    .mockImplementation((owner, repo, pullNumber, token) => {
      if (owner === "errororg") {
        return Promise.reject(new Error("API error"));
      } else if (repo === "emptydiff") {
        return Promise.resolve("");
      } else {
        return Promise.resolve("normal diff content");
      }
    }),
  // 他のメソッドも同様に...
}));

5. テストの可読性と保守性の低下

モックの設定が複雑になると、テストコードの可読性が低下し、保守が難しくなります。また、テストが失敗した場合のデバッグも困難になります。

依存性注入(DI)パターンの導入

依存性注入(Dependency Injection)は、コンポーネントが依存するオブジェクトを外部から注入する設計パターンです。DI を使用すると、実際の実装とテスト用のモック実装を簡単に切り替えることができます。

DI の基本概念

DI の基本的な考え方は、コンポーネントが自分で依存オブジェクトを作成するのではなく、外部から受け取るというものです。これにより、コンポーネントは特定の実装に依存せず、インターフェースに依存するようになります。

TypeScript での DI 実装例

TypeScript では、インターフェースを使って DI を実装できます。以下は、GitHub クライアントのインターフェースとその実装例です。

// インターフェース定義
export interface IGitHubClient {
  fetchPullRequestDiff(
    owner: string,
    repo: string,
    pullNumber: number,
    token: string
  ): Promise<string>;

  fetchCommitsDiff(
    owner: string,
    repo: string,
    base: string,
    head: string,
    token: string
  ): Promise<string>;

  // 他のメソッド...
}

// 実際の実装
export class GitHubClient implements IGitHubClient {
  async fetchPullRequestDiff(
    owner: string,
    repo: string,
    pullNumber: number,
    token: string
  ): Promise<string> {
    // 実際のAPI呼び出し
    // ...
  }

  async fetchCommitsDiff(
    owner: string,
    repo: string,
    base: string,
    head: string,
    token: string
  ): Promise<string> {
    // 実際のAPI呼び出し
    // ...
  }

  // 他のメソッド...
}

// シングルトンインスタンス
export const githubClient = new GitHubClient();

DI を使用したサービス実装

DI を使用すると、サービスは依存オブジェクトを引数として受け取ります。デフォルト値を設定することで、既存のコードへの影響を最小限に抑えることができます。

import { IGitHubClient } from "../shared/interfaces/github-client.interface";
import { githubClient } from "../shared/github-client";
import { GitHubParams } from "../shared/types";

/**
 * GitHub APIからdiffを取得する関数
 */
export async function fetchDiff(
  params: GitHubParams,
  client: IGitHubClient = githubClient
): Promise<string> {
  const { owner, repo, pull_number, base, head, token } = params;
  // PRのdiffを取得するか、コミット間のdiffを取得するかを判断
  if (pull_number !== undefined) {
    // PRのdiffを取得
    return await client.fetchPullRequestDiff(owner, repo, pull_number, token);
  } else if (base !== undefined && head !== undefined) {
    // コミット間のdiffを取得
    return await client.fetchCommitsDiff(owner, repo, base, head, token);
  } else {
    throw new Error(
      "pull_numberまたは(base, head)のいずれかを指定してください"
    );
  }
}

DI を使用したルーター実装

Express ルーターも同様に、依存オブジェクトを引数として受け取ることができます。

import express from "express";
import { fetchDiff } from "../services/github";
import { IGitHubClient } from "../shared/interfaces/github-client.interface";
import { githubClient } from "../shared/github-client";

export function createGithubDiffRouter(client: IGitHubClient = githubClient) {
  const router = express.Router();

  router.post("/", async (req, res, next) => {
    try {
      const params = { ... };

      const rawDiffText = await fetchDiff(params, client);

      res.json({ diff: filteredDiffText });
    } catch (error) {
      next(error);
    }
  });

  return router;
}

// デフォルトのルーターをエクスポート
export default createGithubDiffRouter();

DI を使用したアプリケーション構成

アプリケーション全体で DI を使用するには、依存関係をまとめて注入できる構造が必要です。

import express from "express";
import cors from "cors";
import { createGithubDiffRouter } from "./routes/github-diff";
import { authMiddleware } from "./middleware/auth";
import { errorHandler } from "./middleware/error-handler";
import { IGitHubClient } from "./shared/interfaces/github-client.interface";
import { githubClient } from "./shared/github-client";

export interface AppDependencies {
  githubClient?: IGitHubClient;
}

export function createApp(dependencies: AppDependencies = {}) {
  const { githubClient: client = githubClient } = dependencies;

  const app = express();

  app.use(cors());
  app.use(express.json());

  // GitHub Diffルーターを設定(依存関係を注入)
  app.use("/api/github-diff", authMiddleware, createGithubDiffRouter(client));

  // エラーハンドラー
  app.use(errorHandler);

  return app;
}

jest.mock/spyOn と DI パターンの比較

ここでは、jest.mock/spyOn と DI パターンを様々な観点から比較します。

1. テストの質

テストの信頼性

jest.mock/spyOn:

  • モックの設定がグローバルに影響するため、テスト間の干渉が発生しやすい
  • テストの順序に依存する場合がある
  • モックの設定忘れや不適切な設定によるフォールスポジティブ(誤って成功するテスト)が発生しやすい

DI パターン:

  • 各テストケースで独立したモックインスタンスを使用するため、テスト間の干渉がない
  • テストの順序に依存しない
  • モックの状態が明示的に管理されるため、false ポジティブが発生しにくい

テストの再現性

jest.mock/spyOn:

  • モックの設定が複雑な場合、テストの再現性が低下する
  • テスト環境によって動作が異なる場合がある

DI パターン:

  • モックの状態が明示的に管理されるため、テストの再現性が高い
  • テスト環境に依存せず、一貫した動作が期待できる

デバッグのしやすさ

jest.mock/spyOn:

  • モックの設定が複雑な場合、デバッグが困難
  • エラーメッセージが分かりにくい場合がある

DI パターン:

  • モックの状態が明示的に管理されるため、デバッグが容易
  • エラーの原因が特定しやすい

2. 保守性

jest.mock/spyOn:

  • モジュールのインポートパスや内部実装の詳細に依存するため、リファクタリングに弱い
  • コードの変更に伴うテストの修正が多くなりがち

DI パターン:

  • インターフェースに依存するため、実装の詳細が変わってもテストは影響を受けにくい
  • リファクタリングに強い

3. 再利用性

jest.mock/spyOn:

  • モックの設定が特定のテストケースに依存するため、再利用性が低い
  • 複雑なモック設定を再利用するには、ヘルパー関数を作成する必要がある

DI パターン:

  • モッククラスを作成することで、複数のテストケースで再利用できる
  • モッククラスの振る舞いを簡単にカスタマイズできる

4. 学習コスト

jest.mock/spyOn:

  • Jest の基本機能なので、学習コストが低い
  • 単純なケースでは簡単に使える

DI パターン:

  • DI の概念を理解する必要があるため、学習コストが高い
  • 初期設定が複雑

5. 実装コスト

jest.mock/spyOn:

  • 既存のコードを変更せずにテストできる
  • 追加のインターフェースやクラスが不要

DI パターン:

  • インターフェースの定義や実装の修正が必要
  • 初期の実装コストが高い

実際のプロジェクトでの適用例:/api/github-diff エンドポイントのテスト実装

ここでは、DI パターンを使用した /api/github-diff エンドポイントのテスト実装例を紹介します。

テスト用のダミー GitHubClient

まず、テスト用のダミー GitHubClient を実装します。このクラスは、IGitHubClient インターフェースを実装し、テスト用の振る舞いを提供します。

import { IGitHubClient } from "../../shared/interfaces/github-client.interface";
import { GitHubCommentResponse } from "../../shared/types";

export class DummyGitHubClient implements IGitHubClient {
  private diffContent: string = "dummy diff content";
  private shouldThrowError: boolean = false;
  private errorMessage: string = "GitHub API error";

  // テスト用に返す値を設定できるようにする
  setDiffContent(content: string): void {
    this.diffContent = content;
  }

  // エラーをスローするように設定
  setShouldThrowError(
    shouldThrow: boolean,
    message: string = "GitHub API error"
  ): void {
    this.shouldThrowError = shouldThrow;
    this.errorMessage = message;
  }

  async fetchPullRequestDiff(
    owner: string,
    repo: string,
    pullNumber: number,
    token: string
  ): Promise<string> {
    if (this.shouldThrowError) {
      throw new Error(this.errorMessage);
    }
    return this.diffContent;
  }

  async fetchCommitsDiff(
    owner: string,
    repo: string,
    base: string,
    head: string,
    token: string
  ): Promise<string> {
    if (this.shouldThrowError) {
      throw new Error(this.errorMessage);
    }
    return this.diffContent;
  }

  // 他のメソッド...
}

テスト用のアプリケーション作成関数

次に、テスト用のアプリケーションを作成する関数を実装します。この関数は、ダミー GitHubClient を注入したアプリケーションを返します。

import { createApp } from "../../app";
import { IGitHubClient } from "../../shared/interfaces/github-client.interface";

export function createTestApp(client: IGitHubClient) {
  return createApp({ githubClient: client });
}

テストファイル

最後に、テストファイルを実装します。このファイルでは、ダミー GitHubClient を使用して様々なテストケースを実行します。

import request from "supertest";
import { DummyGitHubClient } from "../helpers/dummy-github-client";
import { createTestUser, getAuthToken } from "../helpers/test-auth";
import { createTestApp } from "../helpers/create-test-app";

describe("/api/github-diff エンドポイントのテスト", () => {
  const testEmail = "test@example.com";
  const testPassword = "password123";
  let validToken: string;
  const dummyGithubClient = new DummyGitHubClient();
  const app = createTestApp(dummyGithubClient);

  beforeAll(async () => {
    // テストユーザーを作成
    await createTestUser(testEmail, testPassword, {
      name: "Test User",
    });

    // 有効なトークンを取得
    validToken = await getAuthToken(testEmail, testPassword);
  });

  beforeEach(() => {
    // テスト前にダミーの設定をリセット
    dummyGithubClient.setDiffContent("dummy diff content");
    dummyGithubClient.setShouldThrowError(false);
  });

  test("有効なパラメータ(pull_number)でリクエストした場合、フィルタリングされたdiffが返される", async () => {
    // テスト用のdiffを設定
    const testDiff = `diff --git a/src/index.js b/src/index.js
index 1234567..abcdefg 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,5 +1,5 @@
 console.log("Hello");
-console.log("World");
+console.log("Universe");
 console.log("!");`;

    dummyGithubClient.setDiffContent(testDiff);

    // テスト実行
    const response = await request(app)
      .post("/api/github-diff")
      .set("Authorization", `Bearer ${validToken}`)
      .send({
        owner: "testorg",
        repo: "testrepo",
        pull_number: 123,
      });

    // 検証
    expect(response.status).toBe(200);
    expect(response.body.diff).toBe(testDiff); // フィルタリング後のdiff
  });

  test("GitHub APIからのエラーが適切に処理される", async () => {
    // GitHubClientをエラーを返すように設定
    dummyGithubClient.setShouldThrowError(true, "GitHub API error");

    // テスト実行
    const response = await request(app)
      .post("/api/github-diff")
      .set("Authorization", `Bearer ${validToken}`)
      .send({
        owner: "testorg",
        repo: "testrepo",
        pull_number: 123,
      });

    // 検証
    expect(response.status).toBe(500);
    expect(response.body.error).toBeTruthy();
  });

  // 他のテストケース...
});

DI パターンを使用したテストの利点

このテスト実装には、以下のような利点があります:

  1. テストの独立性: 各テストケースは独立しており、他のテストケースの影響を受けません。
  2. テストの可読性: テストコードが明確で、何をテストしているかが分かりやすいです。
  3. テストの保守性: コードの実装が変わっても、インターフェースが変わらなければテストは影響を受けません。
  4. テストの再利用性: ダミー GitHubClient は他のテストでも再利用できます。
  5. テストのカスタマイズ性: ダミー GitHubClient の振る舞いを簡単にカスタマイズできます。

結論:DI パターンを使用することの利点

DI パターンを使用することで、テストの質を大幅に向上させることができます。特に、以下の点で優れています:

  1. テストの信頼性: テスト間の干渉がなく、false ポジティブが発生しにくい
  2. テストの再現性: モックの状態が明示的に管理され、一貫した動作が期待できる
  3. デバッグのしやすさ: エラーの原因が特定しやすい
  4. 保守性: リファクタリングに強く、コードの変更に伴うテストの修正が少ない
  5. 再利用性: モッククラスを複数のテストケースで再利用できる

一方で、DI パターンには学習コストと初期の実装コストがかかるというデメリットもあります。しかし、長期的には保守性や再利用性の向上によって、これらのコストを上回るメリットがあると言えるでしょう。

特に、大規模なプロジェクトや長期的なメンテナンスが必要なプロジェクトでは、DI パターンを積極的に採用することをお勧めします。

プロジェクトへの適用方法

DI パターンをプロジェクトに適用するには、以下の手順を踏むとよいでしょう:

  1. インターフェースの定義: 外部依存のインターフェースを定義する
  2. 既存の実装の修正: 既存の実装をインターフェースに準拠させる
  3. 依存関係の注入: 関数やクラスが依存関係を引数として受け取るように修正する
  4. テストヘルパーの実装: テスト用のモッククラスやヘルパー関数を実装する
  5. テストの実装: DI パターンを使用したテストを実装する

これらの手順を踏むことで、テストの質を高め、より堅牢なソフトウェアを開発することができるでしょう。

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

村井 謙太

代表取締役

村井 謙太

Twitter

東京大学在学中にプログラミング学習サービスのProgateを立ち上げ、CTOとしてプロダクト開発に従事。 Progate退任後に株式会社Anycloudを立ち上げ、現在は多数のクライアントの技術支援を行っている。