はじめに

「外部APIを呼び出す処理のテストを書きたいが、毎回実際のAPIにアクセスするわけにはいかない」「データベースに依存するコードを、データベースなしでテストしたい」という課題は、単体テストを書く上で必ず直面する問題です。

このような外部依存を分離してテスト可能にする技術がテストダブルであり、その代表格がモックスタブです。しかし、これらの用語は混同されやすく、「何が違うのか」「どう使い分けるのか」がわからないまま使っている開発者も少なくありません。

本記事では、テストダブルの種類と概念を整理し、モック・スタブの正しい使い分けと実践的なコード例を解説します。JestとJUnit 5+Mockitoの両方でサンプルコードを示しますので、JavaScriptとJavaどちらの開発者にも役立つ内容となっています。

テストダブルとは

テストダブルとは、本番コードで使用するオブジェクトの代わりに、テスト目的で使用する代替オブジェクトの総称です。映画のスタントダブル(代役)になぞらえて、Gerard Meszaros氏が「xUnit Test Patterns」で命名しました。

なぜテストダブルが必要なのか

以下のような外部依存を持つコードを考えてみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// userService.js - 外部APIに依存するサービス
export async function getUserProfile(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`)
  if (!response.ok) {
    throw new Error('User not found')
  }
  const user = await response.json()
  return {
    displayName: `${user.firstName} ${user.lastName}`,
    email: user.email,
  }
}

このコードをそのままテストしようとすると、以下の問題が発生します。

問題 影響
ネットワーク依存 テストが不安定になる(ネットワーク障害で失敗)
速度低下 実際のAPI呼び出しに時間がかかる
テストデータ 本番APIのデータが変更されるとテストが壊れる
コスト API呼び出し回数に課金されるサービスもある
副作用 POSTやDELETEのテストで本番データを変更してしまう

テストダブルを使うことで、これらの問題を解決し、高速安定独立した単体テストを実現できます。

テストダブルの5つの種類

テストダブルは、その役割によって5つのカテゴリに分類されます。

flowchart TB
    TD[テストダブル] --> Dummy[ダミー]
    TD --> Stub[スタブ]
    TD --> Spy[スパイ]
    TD --> Mock[モック]
    TD --> Fake[フェイク]
    
    Dummy -->|"引数を埋めるだけ<br/>実際には使われない"| D1["例: null, 空オブジェクト"]
    Stub -->|"事前定義された値を返す<br/>振る舞いの検証なし"| S1["例: 固定値を返すAPI"]
    Spy -->|"呼び出しを記録<br/>本物の処理も実行可能"| Sp1["例: 呼び出し回数の記録"]
    Mock -->|"期待する呼び出しを検証<br/>振る舞いの検証あり"| M1["例: メソッドが呼ばれたか確認"]
    Fake -->|"簡易版の実装<br/>本番と同じインターフェース"| F1["例: インメモリDB"]
    
    style TD fill:#e1f5fe,stroke:#01579b,color:#000000
    style Stub fill:#fff3e0,stroke:#e65100,color:#000000
    style Mock fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style Spy fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style Fake fill:#fce4ec,stroke:#c2185b,color:#000000
    style Dummy fill:#efebe9,stroke:#5d4037,color:#000000
種類 役割 状態の検証 振る舞いの検証
ダミー(Dummy) 引数を埋めるためだけに渡す なし なし
スタブ(Stub) 事前定義された応答を返す あり なし
スパイ(Spy) 呼び出しを記録しつつ、処理も実行 あり あり
モック(Mock) 期待する呼び出しを検証 あり あり
フェイク(Fake) 本番より簡易な動作する実装 あり なし

スタブ(Stub)の使い方

スタブは、テスト対象のコードが依存するオブジェクトの代わりに、事前定義された値を返すオブジェクトです。テスト対象の処理結果(状態)を検証するために使用します。

スタブの特徴

  • 入力に対して固定の出力を返す
  • テスト対象の「状態」を検証する(戻り値、プロパティの変化など)
  • 呼び出し方法は検証しない

JavaScript(Jest)でのスタブ実装

まず、テスト対象のコードを確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// priceCalculator.js
export class PriceCalculator {
  constructor(taxService) {
    this.taxService = taxService
  }

  calculateTotalPrice(basePrice) {
    const taxRate = this.taxService.getTaxRate()
    return Math.floor(basePrice * (1 + taxRate))
  }
}

このPriceCalculatortaxServiceに依存しています。スタブを使って、税率を固定値に差し替えてテストします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// priceCalculator.test.js
import { PriceCalculator } from './priceCalculator'

describe('PriceCalculator', () => {
  describe('calculateTotalPrice', () => {
    it('税率10%で税込価格を計算する', () => {
      // スタブの作成: 固定値を返すオブジェクト
      const taxServiceStub = {
        getTaxRate: () => 0.1, // 常に10%を返す
      }

      const calculator = new PriceCalculator(taxServiceStub)
      const result = calculator.calculateTotalPrice(1000)

      // 状態の検証: 戻り値が正しいか
      expect(result).toBe(1100)
    })

    it('税率8%で税込価格を計算する', () => {
      const taxServiceStub = {
        getTaxRate: () => 0.08, // 軽減税率8%
      }

      const calculator = new PriceCalculator(taxServiceStub)
      const result = calculator.calculateTotalPrice(1000)

      expect(result).toBe(1080)
    })

    it('小数点以下は切り捨てられる', () => {
      const taxServiceStub = {
        getTaxRate: () => 0.1,
      }

      const calculator = new PriceCalculator(taxServiceStub)
      const result = calculator.calculateTotalPrice(999)

      expect(result).toBe(1098) // 999 * 1.1 = 1098.9 → 1098
    })
  })
})

Java(Mockito)でのスタブ実装

Javaでは、Mockitoのwhen().thenReturn()メソッドを使ってスタブを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// TaxService.java
public interface TaxService {
    double getTaxRate();
}

// PriceCalculator.java
public class PriceCalculator {
    private final TaxService taxService;

    public PriceCalculator(TaxService taxService) {
        this.taxService = taxService;
    }

    public int calculateTotalPrice(int basePrice) {
        double taxRate = taxService.getTaxRate();
        return (int) Math.floor(basePrice * (1 + taxRate));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// PriceCalculatorTest.java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class PriceCalculatorTest {

    @Mock
    TaxService taxService;

    @Nested
    @DisplayName("calculateTotalPrice")
    class CalculateTotalPrice {

        @Test
        @DisplayName("税率10%で税込価格を計算する")
        void calculatesWith10PercentTax() {
            // スタブの設定: getTaxRate()が呼ばれたら0.1を返す
            when(taxService.getTaxRate()).thenReturn(0.1);

            PriceCalculator calculator = new PriceCalculator(taxService);
            int result = calculator.calculateTotalPrice(1000);

            // 状態の検証
            assertThat(result).isEqualTo(1100);
        }

        @Test
        @DisplayName("税率8%で税込価格を計算する")
        void calculatesWith8PercentTax() {
            when(taxService.getTaxRate()).thenReturn(0.08);

            PriceCalculator calculator = new PriceCalculator(taxService);
            int result = calculator.calculateTotalPrice(1000);

            assertThat(result).isEqualTo(1080);
        }

        @Test
        @DisplayName("小数点以下は切り捨てられる")
        void truncatesDecimalPart() {
            when(taxService.getTaxRate()).thenReturn(0.1);

            PriceCalculator calculator = new PriceCalculator(taxService);
            int result = calculator.calculateTotalPrice(999);

            assertThat(result).isEqualTo(1098);
        }
    }
}

スタブを使うべきシーン

シーン 具体例
外部サービスからのデータ取得 API、データベース、ファイル読み込み
設定値の取得 環境設定、フィーチャーフラグ
現在日時の取得 new Date()LocalDateTime.now()
ランダム値の生成 Math.random()、乱数生成器

モック(Mock)の使い方

モックは、テスト対象のコードが依存オブジェクトを正しく呼び出しているかを検証するオブジェクトです。スタブが「状態」を検証するのに対し、モックは「振る舞い」を検証します。

モックの特徴

  • メソッドが呼び出されたかを検証
  • 呼び出し回数、引数の内容を検証可能
  • テスト対象の「振る舞い」を検証する

JavaScript(Jest)でのモック実装

メール送信を行うサービスを例に、モックの使い方を解説します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// orderService.js
export class OrderService {
  constructor(emailService) {
    this.emailService = emailService
  }

  placeOrder(order) {
    // 注文処理(省略)
    const orderId = `ORD-${Date.now()}`

    // 注文確認メールを送信
    this.emailService.sendEmail(
      order.customerEmail,
      '注文確認',
      `ご注文ありがとうございます。注文番号: ${orderId}`
    )

    return orderId
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// orderService.test.js
import { OrderService } from './orderService'

describe('OrderService', () => {
  describe('placeOrder', () => {
    it('注文確認メールを顧客に送信する', () => {
      // モックの作成
      const emailServiceMock = {
        sendEmail: jest.fn(), // 呼び出しを記録するモック関数
      }

      const orderService = new OrderService(emailServiceMock)
      const order = {
        customerEmail: 'customer@example.com',
        items: [{ name: '商品A', price: 1000 }],
      }

      orderService.placeOrder(order)

      // 振る舞いの検証: sendEmailが正しく呼び出されたか
      expect(emailServiceMock.sendEmail).toHaveBeenCalledTimes(1)
      expect(emailServiceMock.sendEmail).toHaveBeenCalledWith(
        'customer@example.com',
        '注文確認',
        expect.stringContaining('ご注文ありがとうございます')
      )
    })

    it('注文がなくてもメールは1回だけ送信される', () => {
      const emailServiceMock = {
        sendEmail: jest.fn(),
      }

      const orderService = new OrderService(emailServiceMock)
      orderService.placeOrder({ customerEmail: 'test@example.com', items: [] })

      expect(emailServiceMock.sendEmail).toHaveBeenCalledTimes(1)
    })
  })
})

Java(Mockito)でのモック実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// EmailService.java
public interface EmailService {
    void sendEmail(String to, String subject, String body);
}

// OrderService.java
public class OrderService {
    private final EmailService emailService;

    public OrderService(EmailService emailService) {
        this.emailService = emailService;
    }

    public String placeOrder(Order order) {
        String orderId = "ORD-" + System.currentTimeMillis();

        emailService.sendEmail(
            order.getCustomerEmail(),
            "注文確認",
            "ご注文ありがとうございます。注文番号: " + orderId
        );

        return orderId;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// OrderServiceTest.java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    EmailService emailService;

    @Test
    @DisplayName("注文確認メールを顧客に送信する")
    void sendsConfirmationEmailToCustomer() {
        OrderService orderService = new OrderService(emailService);
        Order order = new Order("customer@example.com");

        orderService.placeOrder(order);

        // 振る舞いの検証: sendEmailが呼び出されたか
        verify(emailService, times(1)).sendEmail(
            eq("customer@example.com"),
            eq("注文確認"),
            contains("ご注文ありがとうございます")
        );
    }

    @Test
    @DisplayName("送信されたメール本文に注文番号が含まれる")
    void emailBodyContainsOrderId() {
        OrderService orderService = new OrderService(emailService);
        Order order = new Order("customer@example.com");

        String orderId = orderService.placeOrder(order);

        // ArgumentCaptorで引数をキャプチャ
        ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
        verify(emailService).sendEmail(any(), any(), bodyCaptor.capture());

        assertThat(bodyCaptor.getValue()).contains(orderId);
    }
}

モックを使うべきシーン

シーン 具体例
副作用の検証 メール送信、通知、ログ出力
外部システムへの書き込み API呼び出し、データベース更新
イベントの発行 メッセージキューへの送信、イベント発火
コールバックの呼び出し リスナーの通知、オブザーバーへの通知

スタブとモックの使い分け

スタブとモックは似ているようで、検証の対象が根本的に異なります。Martin Fowler氏は「Mocks Aren’t Stubs」という記事で、この違いを明確に説明しています。

状態検証 vs 振る舞い検証

flowchart LR
    subgraph stub["スタブ(状態検証)"]
        S1["入力"] --> S2["テスト対象"]
        S2 --> S3["出力"]
        S3 --> S4["戻り値を検証"]
    end
    
    subgraph mock["モック(振る舞い検証)"]
        M1["入力"] --> M2["テスト対象"]
        M2 --> M3["依存オブジェクト"]
        M3 --> M4["呼び出しを検証"]
    end
    
    style S4 fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style M4 fill:#f3e5f5,stroke:#7b1fa2,color:#000000
観点 スタブ モック
目的 テスト対象が正しい結果を返すか テスト対象が正しく依存を呼び出すか
検証対象 戻り値、プロパティの変化(状態) メソッド呼び出し、引数(振る舞い)
設定 when().thenReturn() verify()
主な用途 クエリ(データの取得) コマンド(処理の実行)

使い分けの判断基準

以下のフローチャートで、スタブとモックのどちらを使うべきかを判断できます。

flowchart TD
    A[依存オブジェクトのメソッド] --> B{戻り値を使用するか?}
    B -->|Yes| C{副作用があるか?}
    B -->|No| D[モックを使用<br/>呼び出しを検証]
    C -->|No| E[スタブを使用<br/>固定値を返す]
    C -->|Yes| F[両方を使用<br/>値を返し+呼び出しを検証]
    
    style E fill:#fff3e0,stroke:#e65100,color:#000000
    style D fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style F fill:#e1f5fe,stroke:#01579b,color:#000000

具体例: リポジトリパターンでの使い分け

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// userRepository.js
export class UserRepository {
  constructor(database) {
    this.database = database
  }

  findById(id) {
    return this.database.query('SELECT * FROM users WHERE id = ?', [id])
  }

  save(user) {
    this.database.execute('INSERT INTO users VALUES (?, ?, ?)', [
      user.id,
      user.name,
      user.email,
    ])
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// userRepository.test.js
describe('UserRepository', () => {
  describe('findById - スタブを使用', () => {
    it('ユーザーを取得できる', async () => {
      // スタブ: クエリ結果を固定
      const databaseStub = {
        query: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
      }

      const repository = new UserRepository(databaseStub)
      const user = await repository.findById(1)

      // 状態を検証
      expect(user).toEqual({ id: 1, name: 'Alice' })
    })
  })

  describe('save - モックを使用', () => {
    it('ユーザーをデータベースに保存する', () => {
      // モック: 呼び出しを検証
      const databaseMock = {
        execute: jest.fn(),
      }

      const repository = new UserRepository(databaseMock)
      repository.save({ id: 1, name: 'Alice', email: 'alice@example.com' })

      // 振る舞いを検証
      expect(databaseMock.execute).toHaveBeenCalledWith(
        'INSERT INTO users VALUES (?, ?, ?)',
        [1, 'Alice', 'alice@example.com']
      )
    })
  })
})

スパイ(Spy)の使い方

スパイは、実際のオブジェクトの処理を実行しつつ、呼び出しを記録するテストダブルです。「部分的なモック」とも呼ばれます。

JavaScript(Jest)でのスパイ実装

1
2
3
4
5
6
7
8
9
// logger.js
export const logger = {
  log(message) {
    console.log(`[LOG] ${new Date().toISOString()}: ${message}`)
  },
  error(message) {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`)
  },
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// logger.test.js
import { logger } from './logger'

describe('logger', () => {
  it('logメソッドの呼び出しを記録できる', () => {
    // 実際のconsole.logにスパイを設定
    const consoleSpy = jest.spyOn(console, 'log').mockImplementation()

    logger.log('テストメッセージ')

    expect(consoleSpy).toHaveBeenCalledTimes(1)
    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining('テストメッセージ')
    )

    consoleSpy.mockRestore() // 元に戻す
  })
})

Java(Mockito)でのスパイ実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

class SpyExampleTest {

    @Test
    @DisplayName("スパイは実際のメソッドを呼び出しつつ記録する")
    void spyCallsRealMethodAndRecords() {
        List<String> list = new ArrayList<>();
        List<String> spyList = spy(list);

        // 実際のaddメソッドが呼ばれる
        spyList.add("one");
        spyList.add("two");

        // 状態の検証: 実際に追加されている
        assertThat(spyList).hasSize(2);

        // 振る舞いの検証: 呼び出しを記録している
        verify(spyList, times(2)).add(anyString());
    }

    @Test
    @DisplayName("スパイの一部メソッドだけスタブ化できる")
    void canStubSomeMethodsOfSpy() {
        List<String> list = new ArrayList<>();
        List<String> spyList = spy(list);

        // sizeメソッドだけスタブ化
        doReturn(100).when(spyList).size();

        spyList.add("one");

        // size()はスタブの値を返す
        assertThat(spyList.size()).isEqualTo(100);
        // get()は実際の値を返す
        assertThat(spyList.get(0)).isEqualTo("one");
    }
}

スパイを使うべきシーン

シーン 具体例
レガシーコードのテスト 依存注入されていないクラスの一部をモック化
一部メソッドのみ差し替え 複雑なクラスの特定メソッドだけスタブ化
実際の処理の記録 処理は実行しつつ呼び出しを検証

フェイク(Fake)の使い方

フェイクは、本番環境と同じインターフェースを持ちながら、簡易的な実装を提供するテストダブルです。インメモリデータベースや、ファイルシステムをメモリ上で再現するオブジェクトなどが該当します。

JavaScript でのフェイク実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// fakeUserRepository.js - インメモリ実装
export class FakeUserRepository {
  constructor() {
    this.users = new Map()
  }

  async findById(id) {
    return this.users.get(id) || null
  }

  async save(user) {
    this.users.set(user.id, user)
  }

  async findAll() {
    return Array.from(this.users.values())
  }

  async deleteById(id) {
    this.users.delete(id)
  }

  // テスト用ヘルパー
  clear() {
    this.users.clear()
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// userService.test.js
import { UserService } from './userService'
import { FakeUserRepository } from './fakeUserRepository'

describe('UserService with FakeRepository', () => {
  let userService
  let fakeRepository

  beforeEach(() => {
    fakeRepository = new FakeUserRepository()
    userService = new UserService(fakeRepository)
  })

  it('ユーザーを作成して取得できる', async () => {
    await userService.createUser({ id: 1, name: 'Alice' })

    const user = await userService.getUser(1)

    expect(user).toEqual({ id: 1, name: 'Alice' })
  })

  it('存在しないユーザーはnullを返す', async () => {
    const user = await userService.getUser(999)

    expect(user).toBeNull()
  })

  it('複数ユーザーを扱える', async () => {
    await userService.createUser({ id: 1, name: 'Alice' })
    await userService.createUser({ id: 2, name: 'Bob' })

    const users = await fakeRepository.findAll()

    expect(users).toHaveLength(2)
  })
})

フェイクを使うべきシーン

シーン 具体例
統合テスト インメモリDBでリポジトリ層をテスト
E2Eテスト 外部APIのフェイクサーバー
開発環境 本番APIの代替として使用

実践パターン: 外部APIのモック化

実際のプロジェクトで頻出する「外部APIのモック化」パターンを詳しく解説します。

パターン1: HTTPクライアントのモック

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// weatherService.js
export class WeatherService {
  constructor(httpClient) {
    this.httpClient = httpClient
  }

  async getCurrentWeather(city) {
    const response = await this.httpClient.get(
      `https://api.weather.example.com/current?city=${city}`
    )
    return {
      city: response.data.name,
      temperature: response.data.main.temp,
      description: response.data.weather[0].description,
    }
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// weatherService.test.js
import { WeatherService } from './weatherService'

describe('WeatherService', () => {
  it('天気情報を取得して整形する', async () => {
    // HTTPクライアントのスタブ
    const httpClientStub = {
      get: jest.fn().mockResolvedValue({
        data: {
          name: 'Tokyo',
          main: { temp: 25.5 },
          weather: [{ description: 'sunny' }],
        },
      }),
    }

    const service = new WeatherService(httpClientStub)
    const weather = await service.getCurrentWeather('Tokyo')

    expect(weather).toEqual({
      city: 'Tokyo',
      temperature: 25.5,
      description: 'sunny',
    })
  })

  it('APIエラー時に例外をスローする', async () => {
    const httpClientStub = {
      get: jest.fn().mockRejectedValue(new Error('API Error')),
    }

    const service = new WeatherService(httpClientStub)

    await expect(service.getCurrentWeather('Unknown')).rejects.toThrow(
      'API Error'
    )
  })
})

パターン2: 日時のモック

テストでnew Date()Date.now()を使うと、実行タイミングによって結果が変わります。日時をモック化して安定したテストを書きましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// subscriptionService.js
export class SubscriptionService {
  constructor(clock = () => new Date()) {
    this.clock = clock
  }

  isExpired(subscription) {
    const now = this.clock()
    return new Date(subscription.expiresAt) < now
  }

  getDaysUntilExpiry(subscription) {
    const now = this.clock()
    const expiresAt = new Date(subscription.expiresAt)
    const diffTime = expiresAt - now
    return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// subscriptionService.test.js
import { SubscriptionService } from './subscriptionService'

describe('SubscriptionService', () => {
  // 固定日時を返すスタブ
  const fixedDate = new Date('2026-01-05T12:00:00Z')
  const clockStub = () => fixedDate

  describe('isExpired', () => {
    it('期限切れの場合trueを返す', () => {
      const service = new SubscriptionService(clockStub)
      const subscription = { expiresAt: '2026-01-04T00:00:00Z' }

      expect(service.isExpired(subscription)).toBe(true)
    })

    it('期限内の場合falseを返す', () => {
      const service = new SubscriptionService(clockStub)
      const subscription = { expiresAt: '2026-01-10T00:00:00Z' }

      expect(service.isExpired(subscription)).toBe(false)
    })
  })

  describe('getDaysUntilExpiry', () => {
    it('残り日数を計算する', () => {
      const service = new SubscriptionService(clockStub)
      const subscription = { expiresAt: '2026-01-10T12:00:00Z' }

      expect(service.getDaysUntilExpiry(subscription)).toBe(5)
    })
  })
})

アンチパターンと注意点

モック・スタブを使う際に陥りやすいアンチパターンと、その回避方法を解説します。

アンチパターン1: モックのしすぎ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 悪い例: すべてをモックしすぎ
it('ユーザーを作成する(過剰なモック)', async () => {
  const mockValidator = { validate: jest.fn().mockReturnValue(true) }
  const mockHasher = { hash: jest.fn().mockReturnValue('hashed') }
  const mockRepository = { save: jest.fn() }
  const mockLogger = { log: jest.fn() }
  const mockEventEmitter = { emit: jest.fn() }

  const service = new UserService(
    mockValidator,
    mockHasher,
    mockRepository,
    mockLogger,
    mockEventEmitter
  )

  await service.createUser({ name: 'Alice', password: 'secret' })

  // 何をテストしているのかわからない
  expect(mockValidator.validate).toHaveBeenCalled()
  expect(mockHasher.hash).toHaveBeenCalled()
  expect(mockRepository.save).toHaveBeenCalled()
})

問題点:

  • テストがモックの設定で埋め尽くされ、本質が見えない
  • 実装の詳細に依存しすぎて、リファクタリング耐性が低い
  • 実際に動作するかどうかの確信が持てない

解決策:

  • 本当に必要なモックだけを使う
  • フェイクを活用して統合テストを書く
  • テストピラミッドを意識する

アンチパターン2: 実装の詳細をテストする

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 悪い例: 実装の詳細に依存
it('save時にvalidateが先に呼ばれる(順序に依存)', () => {
  const mock = {
    validate: jest.fn().mockReturnValue(true),
    save: jest.fn(),
  }

  service.process(data)

  // 呼び出し順序をテスト - 実装変更で壊れやすい
  const validateCallOrder = mock.validate.mock.invocationCallOrder[0]
  const saveCallOrder = mock.save.mock.invocationCallOrder[0]
  expect(validateCallOrder).toBeLessThan(saveCallOrder)
})

解決策:

  • 呼び出し順序ではなく、最終的な結果(状態)を検証する
  • 振る舞いの検証は「必要なメソッドが呼ばれたか」に留める

アンチパターン3: モックの戻り値と実装の乖離

1
2
3
4
5
6
// 悪い例: 実際のAPIレスポンスと異なる構造
const apiStub = {
  getUser: jest.fn().mockResolvedValue({
    name: 'Alice', // 実際のAPIは { user: { name: 'Alice' } }
  }),
}

解決策:

  • 実際のAPIレスポンスをコピーしてスタブに使用
  • 型定義を活用して整合性を保つ
  • 契約テスト(Contract Testing)を導入する

まとめ

テストダブル(モック・スタブ・スパイ・フェイク)は、外部依存を分離して単体テストを実現するための重要な技術です。

種類 主な用途 検証対象
スタブ 固定値を返す 状態(戻り値)
モック 呼び出しを検証 振る舞い(メソッド呼び出し)
スパイ 実処理+記録 状態と振る舞い
フェイク 簡易実装 状態

使い分けのポイントは以下のとおりです。

  • データを取得するメソッド → スタブで固定値を返す
  • 副作用を持つメソッド → モックで呼び出しを検証
  • レガシーコードの一部をテスト → スパイで部分的にモック化
  • 統合テストの外部依存 → フェイクで簡易実装

過剰なモックはテストの価値を下げます。「何を検証したいのか」を明確にし、必要最小限のテストダブルで効果的なテストを書きましょう。

参考リンク