10日で覚えるJestDay 4: モック・スタブ・スパイ

Day 4: モック・スタブ・スパイ

今日学ぶこと

  • テストダブルとは何か(モック、スタブ、スパイの違い)
  • jest.fn() でモック関数を作成する
  • モック関数の戻り値と実装を制御する
  • jest.spyOn() で既存メソッドを監視する
  • jest.mock() でモジュール全体をモックする
  • モックのリセットとクリーンアップ

テストダブルとは

ユニットテストでは、テスト対象の関数が外部のモジュール(API、データベース、ファイルシステムなど)に依存していることがあります。これらの外部依存をテスト用の代役に置き換えることをテストダブルと呼びます。

flowchart TB
    subgraph Real["本番コード"]
        CODE["関数"]
        API["外部API"]
        DB["データベース"]
        FS["ファイルシステム"]
    end
    subgraph Test["テストコード"]
        TCODE["関数"]
        MOCK_API["モックAPI"]
        MOCK_DB["モックDB"]
        MOCK_FS["モックFS"]
    end
    CODE --> API
    CODE --> DB
    CODE --> FS
    TCODE --> MOCK_API
    TCODE --> MOCK_DB
    TCODE --> MOCK_FS
    style Real fill:#ef4444,color:#fff
    style Test fill:#22c55e,color:#fff

テストダブルの種類

種類 目的 Jestでの実現方法
スタブ(Stub) 固定の値を返す代役 jest.fn().mockReturnValue()
モック(Mock) 呼び出しを記録し検証する代役 jest.fn()
スパイ(Spy) 本物の実装を維持しつつ呼び出しを監視 jest.spyOn()
flowchart LR
    subgraph Doubles["テストダブルの種類"]
        STUB["スタブ\n固定値を返す"]
        MOCK["モック\n呼び出しを記録"]
        SPY["スパイ\n本物を監視"]
    end
    STUB -->|"戻り値の制御"| U1["入力に対する\n出力をテスト"]
    MOCK -->|"呼び出しの検証"| U2["正しく呼ばれたか\nをテスト"]
    SPY -->|"元の実装を保持"| U3["副作用を\n監視してテスト"]
    style STUB fill:#3b82f6,color:#fff
    style MOCK fill:#8b5cf6,color:#fff
    style SPY fill:#22c55e,color:#fff

jest.fn() — モック関数の基本

jest.fn() はモック関数を作成します。呼び出し回数、引数、戻り値をすべて記録します。

test('jest.fn() creates a mock function', () => {
  const mockFn = jest.fn();

  mockFn('hello');
  mockFn('world');

  // called twice
  expect(mockFn).toHaveBeenCalledTimes(2);

  // called with specific arguments
  expect(mockFn).toHaveBeenCalledWith('hello');
  expect(mockFn).toHaveBeenCalledWith('world');

  // last called with
  expect(mockFn).toHaveBeenLastCalledWith('world');
});

TypeScript版:

test('jest.fn() creates a mock function', () => {
  const mockFn = jest.fn<void, [string]>();

  mockFn('hello');
  mockFn('world');

  expect(mockFn).toHaveBeenCalledTimes(2);
  expect(mockFn).toHaveBeenCalledWith('hello');
  expect(mockFn).toHaveBeenLastCalledWith('world');
});

モック関数のマッチャー

マッチャー 説明
toHaveBeenCalled() 1回以上呼ばれた
toHaveBeenCalledTimes(n) n回呼ばれた
toHaveBeenCalledWith(arg1, arg2, ...) 特定の引数で呼ばれた
toHaveBeenLastCalledWith(arg1, ...) 最後の呼び出しの引数
toHaveBeenNthCalledWith(n, arg1, ...) n回目の呼び出しの引数
toHaveReturned() 正常にreturnした
toHaveReturnedWith(value) 特定の値をreturnした

戻り値の制御

mockReturnValue — 固定の戻り値

test('mockReturnValue returns a fixed value', () => {
  const getPrice = jest.fn().mockReturnValue(100);

  expect(getPrice()).toBe(100);
  expect(getPrice()).toBe(100); // always returns 100
});

mockReturnValueOnce — 1回だけの戻り値

test('mockReturnValueOnce returns different values per call', () => {
  const random = jest.fn()
    .mockReturnValueOnce(1)
    .mockReturnValueOnce(2)
    .mockReturnValueOnce(3);

  expect(random()).toBe(1);
  expect(random()).toBe(2);
  expect(random()).toBe(3);
  expect(random()).toBeUndefined(); // no more mocked values
});

mockResolvedValue — Promiseの戻り値

非同期関数のモックには mockResolvedValue を使います。

test('mockResolvedValue returns a resolved promise', async () => {
  const fetchUser = jest.fn().mockResolvedValue({ name: 'Alice', age: 25 });

  const user = await fetchUser();

  expect(user).toEqual({ name: 'Alice', age: 25 });
  expect(fetchUser).toHaveBeenCalledTimes(1);
});

test('mockRejectedValue returns a rejected promise', async () => {
  const fetchUser = jest.fn().mockRejectedValue(new Error('Network error'));

  await expect(fetchUser()).rejects.toThrow('Network error');
});

TypeScript版:

interface User {
  name: string;
  age: number;
}

test('mockResolvedValue returns a resolved promise', async () => {
  const fetchUser = jest.fn<Promise<User>, []>()
    .mockResolvedValue({ name: 'Alice', age: 25 });

  const user = await fetchUser();

  expect(user).toEqual({ name: 'Alice', age: 25 });
});
メソッド 用途
mockReturnValue(val) 常に固定値を返す
mockReturnValueOnce(val) 1回だけ特定の値を返す
mockResolvedValue(val) 常にresolvedなPromiseを返す
mockResolvedValueOnce(val) 1回だけresolvedなPromiseを返す
mockRejectedValue(err) 常にrejectedなPromiseを返す
mockRejectedValueOnce(err) 1回だけrejectedなPromiseを返す

mockImplementation — カスタム実装

より複雑なモックが必要な場合は mockImplementation を使います。

test('mockImplementation provides custom behavior', () => {
  const add = jest.fn().mockImplementation((a, b) => a + b);

  expect(add(1, 2)).toBe(3);
  expect(add(10, 20)).toBe(30);
});

// shorthand: pass implementation to jest.fn()
test('jest.fn() accepts an implementation directly', () => {
  const add = jest.fn((a, b) => a + b);

  expect(add(1, 2)).toBe(3);
});

実践例: コールバックのテスト

モック関数はコールバックのテストに最適です。

// forEach.js
function forEach(items, callback) {
  for (let i = 0; i < items.length; i++) {
    callback(items[i], i);
  }
}

module.exports = forEach;

TypeScript版:

// forEach.ts
export function forEach<T>(items: T[], callback: (item: T, index: number) => void): void {
  for (let i = 0; i < items.length; i++) {
    callback(items[i], i);
  }
}
// forEach.test.js
const forEach = require('./forEach');

test('calls callback for each item', () => {
  const mockCallback = jest.fn();

  forEach(['a', 'b', 'c'], mockCallback);

  // called 3 times
  expect(mockCallback).toHaveBeenCalledTimes(3);

  // check arguments for each call
  expect(mockCallback).toHaveBeenNthCalledWith(1, 'a', 0);
  expect(mockCallback).toHaveBeenNthCalledWith(2, 'b', 1);
  expect(mockCallback).toHaveBeenNthCalledWith(3, 'c', 2);
});

jest.spyOn() — 既存メソッドの監視

jest.spyOn() は既存のオブジェクトのメソッドを監視します。元の実装は保持されるため、実際の動作を変えずに呼び出し情報を記録できます。

// calculator.js
const calculator = {
  add(a, b) {
    return a + b;
  },
  subtract(a, b) {
    return a - b;
  },
};

module.exports = calculator;
// calculator.test.js
const calculator = require('./calculator');

test('spyOn tracks calls without changing behavior', () => {
  const spy = jest.spyOn(calculator, 'add');

  const result = calculator.add(1, 2);

  expect(result).toBe(3); // original implementation
  expect(spy).toHaveBeenCalledWith(1, 2);
  expect(spy).toHaveBeenCalledTimes(1);

  spy.mockRestore(); // restore original
});

spyOn で戻り値を上書き

test('spyOn can override return value', () => {
  const spy = jest.spyOn(calculator, 'add').mockReturnValue(999);

  const result = calculator.add(1, 2);

  expect(result).toBe(999); // overridden
  expect(spy).toHaveBeenCalledWith(1, 2);

  spy.mockRestore();
});

console.log のスパイ

test('spy on console.log', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation();

  console.log('hello');
  console.log('world');

  expect(spy).toHaveBeenCalledTimes(2);
  expect(spy).toHaveBeenCalledWith('hello');

  spy.mockRestore();
});

TypeScript版:

test('spy on console.log', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation();

  console.log('hello');

  expect(spy).toHaveBeenCalledWith('hello');

  spy.mockRestore();
});
jest.fn() vs jest.spyOn() jest.fn() jest.spyOn()
対象 新しい関数を作成 既存のメソッドを監視
元の実装 なし(デフォルトはundefined返す) 保持される
復元 不要 mockRestore() で復元
用途 コールバック、依存関数の代役 既存コードの監視

jest.mock() — モジュール全体のモック

jest.mock() はモジュール全体をモックに置き換えます。

// userService.js
const axios = require('axios');

async function getUser(id) {
  const response = await axios.get(`https://api.example.com/users/${id}`);
  return response.data;
}

module.exports = { getUser };

TypeScript版:

// userService.ts
import axios from 'axios';

export interface User {
  id: number;
  name: string;
  email: string;
}

export async function getUser(id: number): Promise<User> {
  const response = await axios.get<User>(`https://api.example.com/users/${id}`);
  return response.data;
}
// userService.test.js
const axios = require('axios');
const { getUser } = require('./userService');

jest.mock('axios');

describe('getUser', () => {
  test('fetches user data from API', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    axios.get.mockResolvedValue({ data: mockUser });

    const user = await getUser(1);

    expect(user).toEqual(mockUser);
    expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
    expect(axios.get).toHaveBeenCalledTimes(1);
  });

  test('handles API error', async () => {
    axios.get.mockRejectedValue(new Error('Network error'));

    await expect(getUser(1)).rejects.toThrow('Network error');
  });
});

TypeScript版のテスト:

// userService.test.ts
import axios from 'axios';
import { getUser } from './userService';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('getUser', () => {
  test('fetches user data from API', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    mockedAxios.get.mockResolvedValue({ data: mockUser });

    const user = await getUser(1);

    expect(user).toEqual(mockUser);
    expect(mockedAxios.get).toHaveBeenCalledWith(
      'https://api.example.com/users/1'
    );
  });
});
flowchart TB
    subgraph Without["モックなし"]
        T1["テスト"] --> S1["userService"] --> A1["axios"] --> API["外部API\n(不安定)"]
    end
    subgraph With["モックあり"]
        T2["テスト"] --> S2["userService"] --> A2["axios(モック)\n固定レスポンス"]
    end
    style Without fill:#ef4444,color:#fff
    style With fill:#22c55e,color:#fff

ファクトリ関数によるモック

jest.mock() の第2引数でモックの実装を指定できます。

jest.mock('./logger', () => ({
  log: jest.fn(),
  error: jest.fn(),
  warn: jest.fn(),
}));

モックのリセットとクリーンアップ

テスト間でモックの状態をリセットすることが重要です。

describe('mock cleanup', () => {
  const mockFn = jest.fn();

  afterEach(() => {
    mockFn.mockClear(); // or mockReset() or mockRestore()
  });

  test('first test', () => {
    mockFn('hello');
    expect(mockFn).toHaveBeenCalledTimes(1);
  });

  test('second test starts fresh', () => {
    mockFn('world');
    expect(mockFn).toHaveBeenCalledTimes(1); // not 2
  });
});
メソッド リセット内容
mockClear() 呼び出し記録をクリア(実装は維持)
mockReset() 呼び出し記録 + 実装をリセット(undefinedを返す)
mockRestore() 元の実装を復元(spyOn で使う)
flowchart LR
    subgraph Clear["mockClear()"]
        C1["呼び出し記録 → クリア"]
        C2["実装 → 維持"]
    end
    subgraph Reset["mockReset()"]
        R1["呼び出し記録 → クリア"]
        R2["実装 → リセット"]
    end
    subgraph Restore["mockRestore()"]
        RE1["呼び出し記録 → クリア"]
        RE2["実装 → 元に戻す"]
    end
    style Clear fill:#3b82f6,color:#fff
    style Reset fill:#f59e0b,color:#fff
    style Restore fill:#22c55e,color:#fff

ベストプラクティス: jest.config.jsclearMocks: true を設定すると、各テスト後に自動的に mockClear() が実行されます。

// jest.config.js
module.exports = {
  clearMocks: true,
};

まとめ

概念 説明
テストダブル 外部依存をテスト用の代役に置き換える手法
jest.fn() モック関数を作成。呼び出しを記録
mockReturnValue モックの戻り値を設定
mockResolvedValue モックがPromiseを返すよう設定
mockImplementation モックにカスタム実装を設定
jest.spyOn() 既存メソッドを監視(元の実装を保持)
jest.mock() モジュール全体をモックに置換
mockClear / mockReset / mockRestore モックの状態をリセット

重要ポイント

  1. jest.fn() でコールバックや依存関数をモック化できる
  2. jest.spyOn() は元の実装を保持しつつ監視する
  3. jest.mock() でモジュール全体を置き換えて外部依存を排除できる
  4. テスト間のモック状態のリセットを忘れずに

練習問題

問題1: 基本

以下の notifyUsers 関数のテストを書いてください。sendEmail はモック関数として渡します。

function notifyUsers(users, sendEmail) {
  users.forEach(user => {
    sendEmail(user.email, `Hello, ${user.name}!`);
  });
}

問題2: 応用

以下の fetchAndSave 関数のテストを jest.mock() を使って書いてください。

const api = require('./api');
const db = require('./db');

async function fetchAndSave(id) {
  const data = await api.fetch(id);
  await db.save(data);
  return data;
}

チャレンジ問題

jest.spyOn() を使って、Math.random() を制御するテストを書いてください。Math.random() が常に 0.5 を返すようにして、以下の関数をテストしましょう。

function getRandomItem(arr) {
  const index = Math.floor(Math.random() * arr.length);
  return arr[index];
}

参考リンク


次回予告: Day 5では「非同期コードのテスト」について学びます。async/await、Promise、タイマー(setTimeout/setInterval)のテスト方法を詳しく見ていきましょう!