はじめに

ユニットテストを書く際、外部APIやデータベース、タイマーといった外部依存はテストの大きな障壁となります。テストが不安定になったり、実行時間が長くなったり、本番環境に副作用を及ぼす可能性もあります。

これらの問題を解決するのがテストダブル(モック、スタブ、スパイ)です。Node.js 20以降で安定版となったnode:testモジュールは、強力なモック機能を標準で備えており、外部ライブラリなしでテストダブルを活用できます。

本記事では、node:testのmock機能を使って、関数モック、オブジェクトメソッドのスパイ、タイマーのモック、モジュールモックを実装する方法を実践的なコード例とともに解説します。

テストダブルの基礎知識

テストダブルとは、本番コードで使用するオブジェクトの代わりに、テスト目的で使用する代替オブジェクトの総称です。

テストダブルの種類と使い分け

flowchart LR
    TD[テストダブル] --> Spy[スパイ]
    TD --> Stub[スタブ]
    TD --> Mock[モック]
    
    Spy -->|"呼び出しを記録<br/>本物の処理も実行可能"| SpyUse["呼び出し回数・引数の検証"]
    Stub -->|"事前定義された<br/>値を返す"| StubUse["外部依存の置き換え"]
    Mock -->|"期待する呼び出しを<br/>検証"| MockUse["振る舞いの検証"]
    
    style TD fill:#e1f5fe,stroke:#01579b,color:#000000
    style Spy fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style Stub fill:#fff3e0,stroke:#e65100,color:#000000
    style Mock fill:#f3e5f5,stroke:#7b1fa2,color:#000000
種類 役割 node:testでの実現方法
スパイ(Spy) 呼び出しを記録しつつ、元の処理も実行可能 mock.fn(), mock.method()
スタブ(Stub) 事前定義された応答を返す mock.fn()で実装を差し替え
モック(Mock) 期待する呼び出しパターンを検証 mock.fn()で検証

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

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

1
2
3
4
5
6
7
8
// userService.js
export async function fetchUserProfile(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`)
  if (!response.ok) {
    throw new Error('User not found')
  }
  return response.json()
}

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

問題 影響
ネットワーク依存 テストが不安定になる
速度低下 実際のAPI呼び出しに時間がかかる
テストデータ 本番APIのデータ変更でテストが壊れる
副作用 POST/DELETEで本番データを変更してしまう

テストダブルを使うことで、高速安定独立したユニットテストを実現できます。

前提条件と実行環境

本記事の内容を実践するには、以下の環境が必要です。

項目 要件
Node.js v20.x LTS以上(v22.x推奨)
npm v10.x以上
OS Windows / macOS / Linux
前提知識 JavaScriptの基礎、node:testの基本操作

mock.fn()による関数モック

mock.fn()は、テスト用のモック関数を作成する最も基本的な方法です。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// basic-mock.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('mock.fn()で空のモック関数を作成する', () => {
  // 引数なしで呼び出すと、undefinedを返すモック関数を作成
  const mockFn = mock.fn()
  
  // モック関数を呼び出し
  const result = mockFn('arg1', 'arg2')
  
  // 戻り値はundefined
  assert.strictEqual(result, undefined)
  
  // 呼び出し回数を検証
  assert.strictEqual(mockFn.mock.callCount(), 1)
  
  // 呼び出し時の引数を検証
  const call = mockFn.mock.calls[0]
  assert.deepStrictEqual(call.arguments, ['arg1', 'arg2'])
})

実装を指定したモック関数

モック関数に実装を与えることで、スタブとして機能させることができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// stub-function.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('mock.fn()でスタブを作成する', () => {
  // 実装を指定してモック関数を作成
  const add = mock.fn((a, b) => a + b)
  
  // 通常の関数として使用可能
  assert.strictEqual(add(2, 3), 5)
  assert.strictEqual(add(10, 20), 30)
  
  // 呼び出し履歴を検証
  assert.strictEqual(add.mock.callCount(), 2)
  
  // 各呼び出しの詳細を検証
  assert.deepStrictEqual(add.mock.calls[0].arguments, [2, 3])
  assert.strictEqual(add.mock.calls[0].result, 5)
  
  assert.deepStrictEqual(add.mock.calls[1].arguments, [10, 20])
  assert.strictEqual(add.mock.calls[1].result, 30)
})

非同期関数のモック

Promiseを返す非同期関数もモックできます。

 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
// async-mock.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('非同期関数をモックする', async () => {
  // 非同期関数のモック
  const fetchUser = mock.fn(async (id) => ({
    id,
    name: 'Test User',
    email: 'test@example.com'
  }))
  
  const user = await fetchUser(123)
  
  assert.deepStrictEqual(user, {
    id: 123,
    name: 'Test User',
    email: 'test@example.com'
  })
  
  assert.strictEqual(fetchUser.mock.callCount(), 1)
  assert.deepStrictEqual(fetchUser.mock.calls[0].arguments, [123])
})

test('エラーを投げる非同期モック', async () => {
  const fetchUser = mock.fn(async () => {
    throw new Error('Network error')
  })
  
  await assert.rejects(
    () => fetchUser(999),
    { message: 'Network error' }
  )
})

MockFunctionContextの詳細

mock.fn()で作成したモック関数には、mockプロパティを通じてMockFunctionContextにアクセスできます。

主要なプロパティとメソッド

プロパティ/メソッド 説明
callCount() 呼び出し回数を返す
calls 呼び出し履歴の配列
mockImplementation() 実装を変更する
mockImplementationOnce() 次の1回だけ実装を変更する
resetCalls() 呼び出し履歴をリセット
restore() 元の実装に戻す

callsプロパティの構造

 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
// calls-structure.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('calls配列の構造を確認する', () => {
  const calculator = {
    value: 10,
    multiply(x) {
      return this.value * x
    }
  }
  
  mock.method(calculator, 'multiply')
  
  calculator.multiply(5)
  
  const call = calculator.multiply.mock.calls[0]
  
  // 呼び出し時の引数
  assert.deepStrictEqual(call.arguments, [5])
  
  // 戻り値
  assert.strictEqual(call.result, 50)
  
  // エラー(発生していない場合はundefined)
  assert.strictEqual(call.error, undefined)
  
  // thisコンテキスト
  assert.strictEqual(call.this, calculator)
})

mockImplementationOnce()による動的な振る舞い

 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
// mock-implementation-once.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('mockImplementationOnce()で呼び出しごとに振る舞いを変える', () => {
  let counter = 0
  
  function increment() {
    counter++
    return counter
  }
  
  function incrementByTwo() {
    counter += 2
    return counter
  }
  
  const fn = mock.fn(increment)
  
  // 最初の呼び出しは通常通り
  assert.strictEqual(fn(), 1)
  
  // 次の呼び出しだけ別の実装を使用
  fn.mock.mockImplementationOnce(incrementByTwo)
  assert.strictEqual(fn(), 3) // 1 + 2 = 3
  
  // その後は元の実装に戻る
  assert.strictEqual(fn(), 4) // 3 + 1 = 4
})

mock.method()によるオブジェクトメソッドのスパイ

mock.method()を使うと、既存のオブジェクトのメソッドをスパイしつつ、元の処理を実行させることができます。

基本的な使い方

 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
// spy-method.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('オブジェクトメソッドをスパイする', (t) => {
  const mathService = {
    add(a, b) {
      return a + b
    },
    multiply(a, b) {
      return a * b
    }
  }
  
  // addメソッドをスパイ
  t.mock.method(mathService, 'add')
  
  // 元の実装が実行される
  assert.strictEqual(mathService.add(2, 3), 5)
  assert.strictEqual(mathService.add(10, 20), 30)
  
  // 呼び出しが記録されている
  assert.strictEqual(mathService.add.mock.callCount(), 2)
  assert.deepStrictEqual(mathService.add.mock.calls[0].arguments, [2, 3])
})

実装を差し替える

mock.method()の第3引数で、元の実装を別の実装に差し替えることができます。

 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
// replace-method.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('メソッドの実装を差し替える', (t) => {
  const userRepository = {
    async findById(id) {
      // 本来はデータベースにアクセスする処理
      throw new Error('Database connection required')
    }
  }
  
  // 実装を差し替え
  t.mock.method(userRepository, 'findById', async (id) => ({
    id,
    name: 'Mocked User',
    createdAt: new Date('2025-01-01')
  }))
  
  const user = await userRepository.findById(42)
  
  assert.deepStrictEqual(user, {
    id: 42,
    name: 'Mocked User',
    createdAt: new Date('2025-01-01')
  })
  
  assert.strictEqual(userRepository.findById.mock.callCount(), 1)
})

テストコンテキストを使った自動クリーンアップ

t.mockを使用すると、テスト終了時に自動的にモックがリセットされます。

 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
// auto-cleanup.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

const counter = {
  value: 0,
  increment() {
    this.value++
    return this.value
  }
}

describe('テストコンテキストによる自動クリーンアップ', () => {
  test('最初のテスト', (t) => {
    t.mock.method(counter, 'increment', () => 999)
    
    assert.strictEqual(counter.increment(), 999)
    // テスト終了時にモックは自動的にリセットされる
  })
  
  test('次のテストでは元の実装に戻っている', () => {
    counter.value = 0
    assert.strictEqual(counter.increment(), 1)
  })
})

テストダブルの実践パターン

パターン1: 依存関数をスタブ化する

外部依存を持つ関数をテストする際の典型的なパターンです。

 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
58
59
60
61
62
// notification-service.test.js
import { test, describe, beforeEach } from 'node:test'
import assert from 'node:assert/strict'

// テスト対象のモジュール
const emailClient = {
  send: async (to, subject, body) => {
    // 実際にはメールを送信する処理
    throw new Error('Email service not configured')
  }
}

function createNotificationService(emailClient) {
  return {
    async notifyUser(user, message) {
      const subject = `Notification for ${user.name}`
      await emailClient.send(user.email, subject, message)
      return { success: true, recipient: user.email }
    }
  }
}

describe('NotificationService', () => {
  test('ユーザーに通知を送信できる', async (t) => {
    // emailClient.sendをスタブ化
    t.mock.method(emailClient, 'send', async () => {
      return { messageId: 'msg-123' }
    })
    
    const service = createNotificationService(emailClient)
    const user = { name: 'Alice', email: 'alice@example.com' }
    
    const result = await service.notifyUser(user, 'Hello!')
    
    // 戻り値の検証
    assert.deepStrictEqual(result, {
      success: true,
      recipient: 'alice@example.com'
    })
    
    // emailClient.sendが正しい引数で呼ばれたかを検証
    assert.strictEqual(emailClient.send.mock.callCount(), 1)
    const [to, subject, body] = emailClient.send.mock.calls[0].arguments
    assert.strictEqual(to, 'alice@example.com')
    assert.strictEqual(subject, 'Notification for Alice')
    assert.strictEqual(body, 'Hello!')
  })
  
  test('メール送信エラー時に例外を伝播する', async (t) => {
    t.mock.method(emailClient, 'send', async () => {
      throw new Error('SMTP connection failed')
    })
    
    const service = createNotificationService(emailClient)
    const user = { name: 'Bob', email: 'bob@example.com' }
    
    await assert.rejects(
      () => service.notifyUser(user, 'Test'),
      { message: 'SMTP connection failed' }
    )
  })
})

パターン2: コールバック関数のスパイ

イベントハンドラやコールバックが正しく呼ばれるかを検証します。

 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
// event-handler.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

class EventBus {
  constructor() {
    this.listeners = new Map()
  }
  
  on(event, callback) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, [])
    }
    this.listeners.get(event).push(callback)
  }
  
  emit(event, data) {
    const callbacks = this.listeners.get(event) || []
    callbacks.forEach(cb => cb(data))
  }
}

describe('EventBus', () => {
  test('イベントリスナーが正しく呼ばれる', (t) => {
    const bus = new EventBus()
    const handler = t.mock.fn()
    
    bus.on('user:created', handler)
    bus.emit('user:created', { id: 1, name: 'Alice' })
    
    assert.strictEqual(handler.mock.callCount(), 1)
    assert.deepStrictEqual(handler.mock.calls[0].arguments, [
      { id: 1, name: 'Alice' }
    ])
  })
  
  test('複数回のイベント発火を検証', (t) => {
    const bus = new EventBus()
    const handler = t.mock.fn()
    
    bus.on('tick', handler)
    
    bus.emit('tick', 1)
    bus.emit('tick', 2)
    bus.emit('tick', 3)
    
    assert.strictEqual(handler.mock.callCount(), 3)
    assert.deepStrictEqual(
      handler.mock.calls.map(c => c.arguments[0]),
      [1, 2, 3]
    )
  })
})

パターン3: 条件分岐のテスト

モックを使って様々な条件をシミュレートし、分岐をテストします。

 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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// order-validator.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

const inventoryService = {
  checkStock: async (productId) => {
    throw new Error('Service not available')
  }
}

function createOrderValidator(inventoryService) {
  return {
    async validate(order) {
      const errors = []
      
      for (const item of order.items) {
        const stock = await inventoryService.checkStock(item.productId)
        
        if (stock < item.quantity) {
          errors.push({
            productId: item.productId,
            message: `Insufficient stock: requested ${item.quantity}, available ${stock}`
          })
        }
      }
      
      return {
        valid: errors.length === 0,
        errors
      }
    }
  }
}

describe('OrderValidator', () => {
  test('在庫が十分な場合、注文は有効', async (t) => {
    t.mock.method(inventoryService, 'checkStock', async () => 100)
    
    const validator = createOrderValidator(inventoryService)
    const order = {
      items: [
        { productId: 'P001', quantity: 5 },
        { productId: 'P002', quantity: 10 }
      ]
    }
    
    const result = await validator.validate(order)
    
    assert.deepStrictEqual(result, { valid: true, errors: [] })
    assert.strictEqual(inventoryService.checkStock.mock.callCount(), 2)
  })
  
  test('在庫不足の場合、エラーを返す', async (t) => {
    // 商品ごとに異なる在庫数を返す
    t.mock.method(inventoryService, 'checkStock', async (productId) => {
      return productId === 'P001' ? 3 : 100
    })
    
    const validator = createOrderValidator(inventoryService)
    const order = {
      items: [
        { productId: 'P001', quantity: 5 },
        { productId: 'P002', quantity: 10 }
      ]
    }
    
    const result = await validator.validate(order)
    
    assert.strictEqual(result.valid, false)
    assert.strictEqual(result.errors.length, 1)
    assert.strictEqual(result.errors[0].productId, 'P001')
  })
})

mock.timersによるタイマーのモック

mock.timersを使うと、setTimeoutsetIntervalDateをモックして、時間に依存するテストを即座に実行できます。

setTimeoutのモック

 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
// timer-mock.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

describe('タイマーのモック', () => {
  test('setTimeoutを即座に実行する', (t) => {
    const callback = t.mock.fn()
    
    // setTimeoutをモック化
    t.mock.timers.enable({ apis: ['setTimeout'] })
    
    setTimeout(callback, 5000)
    
    // まだ呼ばれていない
    assert.strictEqual(callback.mock.callCount(), 0)
    
    // 時間を進める
    t.mock.timers.tick(5000)
    
    // コールバックが呼ばれた
    assert.strictEqual(callback.mock.callCount(), 1)
  })
  
  test('複数のタイマーを順序通りに実行する', (t) => {
    const results = []
    
    t.mock.timers.enable({ apis: ['setTimeout'] })
    
    setTimeout(() => results.push('first'), 1000)
    setTimeout(() => results.push('second'), 2000)
    setTimeout(() => results.push('third'), 3000)
    
    t.mock.timers.tick(1500)
    assert.deepStrictEqual(results, ['first'])
    
    t.mock.timers.tick(1000)
    assert.deepStrictEqual(results, ['first', 'second'])
    
    t.mock.timers.tick(1000)
    assert.deepStrictEqual(results, ['first', 'second', 'third'])
  })
})

setIntervalのモック

 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
// interval-mock.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

describe('setIntervalのモック', () => {
  test('定期実行をシミュレートする', (t) => {
    const callback = t.mock.fn()
    
    t.mock.timers.enable({ apis: ['setInterval'] })
    
    setInterval(callback, 1000)
    
    // 3秒経過させる
    t.mock.timers.tick(3000)
    
    // 3回呼ばれている
    assert.strictEqual(callback.mock.callCount(), 3)
  })
  
  test('clearIntervalで停止できる', (t) => {
    const callback = t.mock.fn()
    
    t.mock.timers.enable({ apis: ['setInterval'] })
    
    const id = setInterval(callback, 1000)
    
    t.mock.timers.tick(2500)
    assert.strictEqual(callback.mock.callCount(), 2)
    
    clearInterval(id)
    
    t.mock.timers.tick(2000)
    // 停止後は呼ばれない
    assert.strictEqual(callback.mock.callCount(), 2)
  })
})

Dateのモック

 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
// date-mock.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

describe('Dateのモック', () => {
  test('Date.now()を固定値にする', (t) => {
    const fixedTime = new Date('2025-06-15T10:30:00Z').getTime()
    
    t.mock.timers.enable({ apis: ['Date'], now: fixedTime })
    
    assert.strictEqual(Date.now(), fixedTime)
    
    const date = new Date()
    assert.strictEqual(date.toISOString(), '2025-06-15T10:30:00.000Z')
  })
  
  test('時間を進めるとDateも連動する', (t) => {
    const startTime = new Date('2025-01-01T00:00:00Z').getTime()
    
    t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: startTime })
    
    assert.strictEqual(Date.now(), startTime)
    
    // 1時間進める
    t.mock.timers.tick(60 * 60 * 1000)
    
    const oneHourLater = new Date()
    assert.strictEqual(oneHourLater.toISOString(), '2025-01-01T01:00:00.000Z')
  })
})

タイマーを使った実践的なテスト

 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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// retry-service.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

// リトライ機能を持つサービス
function createRetryService(operation, maxRetries = 3, delayMs = 1000) {
  return {
    async execute() {
      let lastError
      
      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          return await operation()
        } catch (error) {
          lastError = error
          if (attempt < maxRetries) {
            await new Promise(resolve => setTimeout(resolve, delayMs))
          }
        }
      }
      
      throw lastError
    }
  }
}

describe('RetryService', () => {
  test('3回目で成功する場合', async (t) => {
    let attempts = 0
    const operation = t.mock.fn(async () => {
      attempts++
      if (attempts < 3) {
        throw new Error(`Attempt ${attempts} failed`)
      }
      return 'success'
    })
    
    t.mock.timers.enable({ apis: ['setTimeout'] })
    
    const service = createRetryService(operation)
    
    // executeを開始(Promiseを取得するだけで、awaitしない)
    const resultPromise = service.execute()
    
    // 1回目の失敗後、1秒待機
    await Promise.resolve() // マイクロタスクを処理
    t.mock.timers.tick(1000)
    
    // 2回目の失敗後、さらに1秒待機
    await Promise.resolve()
    t.mock.timers.tick(1000)
    
    // 3回目で成功
    const result = await resultPromise
    
    assert.strictEqual(result, 'success')
    assert.strictEqual(operation.mock.callCount(), 3)
  })
  
  test('全てのリトライが失敗した場合', async (t) => {
    const operation = t.mock.fn(async () => {
      throw new Error('Always fails')
    })
    
    t.mock.timers.enable({ apis: ['setTimeout'] })
    
    const service = createRetryService(operation)
    const resultPromise = service.execute()
    
    // リトライ間の待機時間を進める
    for (let i = 0; i < 2; i++) {
      await Promise.resolve()
      t.mock.timers.tick(1000)
    }
    
    await assert.rejects(resultPromise, { message: 'Always fails' })
    assert.strictEqual(operation.mock.callCount(), 3)
  })
})

runAll()で全てのタイマーを即座に実行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// run-all.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('runAll()で全てのタイマーを実行する', (t) => {
  const results = []
  
  t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
  
  setTimeout(() => results.push(1), 1000)
  setTimeout(() => results.push(2), 5000)
  setTimeout(() => results.push(3), 10000)
  
  // 全てのタイマーを即座に実行
  t.mock.timers.runAll()
  
  // 全てのコールバックが実行されている
  assert.deepStrictEqual(results, [1, 2, 3])
  
  // Dateも最後のタイマーの時刻まで進んでいる
  assert.strictEqual(Date.now(), 10000)
})

mock.module()によるモジュールモック

mock.module()を使うと、ESモジュールやCommonJSモジュール全体をモックできます。この機能を使うには、--experimental-test-module-mocksフラグが必要です。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// module-mock.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'

test('組み込みモジュールをモックする', async (t) => {
  // node:fsモジュールをモック
  const mockModule = t.mock.module('node:fs', {
    namedExports: {
      readFileSync: () => 'mocked content',
      existsSync: () => true
    }
  })
  
  // モックされたモジュールをインポート
  const fs = await import('node:fs')
  
  assert.strictEqual(fs.readFileSync('/any/path'), 'mocked content')
  assert.strictEqual(fs.existsSync('/any/path'), true)
  
  // モックをリストア
  mockModule.restore()
})

実行方法

モジュールモックを使用するテストは、以下のコマンドで実行します。

1
node --test --experimental-test-module-mocks module-mock.test.js

package.jsonに追加する場合:

1
2
3
4
5
{
  "scripts": {
    "test:module-mock": "node --test --experimental-test-module-mocks"
  }
}

外部APIクライアントのモック

 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
// api-client-mock.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'

// apiClient.js をモック
// 実際のファイル: export async function fetchData(url) { ... }

describe('APIクライアントのモック', () => {
  test('fetchDataをモックして固定値を返す', async (t) => {
    const mockData = { users: [{ id: 1, name: 'Alice' }] }
    
    const mock = t.mock.module('./apiClient.js', {
      namedExports: {
        fetchData: async () => mockData
      }
    })
    
    // モジュールを動的にインポート
    const { fetchData } = await import('./apiClient.js')
    
    const result = await fetchData('https://api.example.com/users')
    
    assert.deepStrictEqual(result, mockData)
    
    mock.restore()
  })
})

テストダブルの使い分けガイドライン

いつスパイを使うか

スパイが適切なケース:

  • 既存のメソッドが正しい引数で呼ばれたかを検証したい
  • 呼び出し回数を確認したい
  • 元の実装はそのまま実行させたい
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
test('スパイの使用例', (t) => {
  const logger = {
    log(message) {
      console.log(`[LOG] ${message}`)
    }
  }
  
  // 元の実装を維持しつつ、呼び出しを記録
  t.mock.method(logger, 'log')
  
  logger.log('Hello')
  
  assert.strictEqual(logger.log.mock.callCount(), 1)
  assert.deepStrictEqual(logger.log.mock.calls[0].arguments, ['Hello'])
})

いつスタブを使うか

スタブが適切なケース:

  • 外部依存(API、DB、ファイルシステム)を置き換えたい
  • 特定の戻り値でテストしたい
  • エラーケースをシミュレートしたい
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
test('スタブの使用例', async (t) => {
  const database = {
    query: async () => { throw new Error('DB not connected') }
  }
  
  // 実装を完全に差し替え
  t.mock.method(database, 'query', async () => [
    { id: 1, name: 'Test' }
  ])
  
  const result = await database.query('SELECT * FROM users')
  assert.deepStrictEqual(result, [{ id: 1, name: 'Test' }])
})

いつモック(振る舞い検証)を使うか

モックが適切なケース:

  • 特定のメソッドが特定の引数で呼ばれることを厳密に検証したい
  • 呼び出し順序が重要な場合
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
test('モックによる振る舞い検証', async (t) => {
  const auditLogger = {
    logAction: t.mock.fn()
  }
  
  const userService = {
    async deleteUser(id) {
      // 削除処理...
      auditLogger.logAction('DELETE', 'user', id)
    }
  }
  
  await userService.deleteUser(42)
  
  // 厳密な振る舞い検証
  assert.strictEqual(auditLogger.logAction.mock.callCount(), 1)
  assert.deepStrictEqual(
    auditLogger.logAction.mock.calls[0].arguments,
    ['DELETE', 'user', 42]
  )
})

判断フローチャート

flowchart TD
    A[外部依存のテスト] --> B{元の実装を<br/>実行したいか?}
    B -->|Yes| C[スパイを使用]
    B -->|No| D{特定の戻り値が<br/>必要か?}
    D -->|Yes| E[スタブを使用]
    D -->|No| F{呼び出しパターンを<br/>検証したいか?}
    F -->|Yes| G[モックを使用]
    F -->|No| E
    
    style A fill:#e1f5fe,stroke:#01579b,color:#000000
    style C fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style E fill:#fff3e0,stroke:#e65100,color:#000000
    style G fill:#f3e5f5,stroke:#7b1fa2,color:#000000

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

1. テストごとにモックをリセットする

t.mockを使用すると自動的にリセットされますが、グローバルなmockを使う場合は明示的にリセットが必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { test, mock, afterEach } from 'node:test'

afterEach(() => {
  mock.reset()
})

test('グローバルモックを使用', () => {
  const fn = mock.fn()
  fn()
  // ...
})

2. 過度なモックを避ける

モックしすぎると、実際の動作と乖離したテストになってしまいます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 悪い例: 全てをモック
test('過度なモック', (t) => {
  const service = {
    process: t.mock.fn(() => 'mocked'),
    validate: t.mock.fn(() => true),
    transform: t.mock.fn((x) => x),
    save: t.mock.fn(() => Promise.resolve())
  }
  // 何もテストしていない
})

// 良い例: 外部境界のみモック
test('適切なモック', (t) => {
  const externalApi = {
    fetch: t.mock.fn(async () => ({ data: 'test' }))
  }
  const service = createService(externalApi)
  // 実際のロジックをテスト
})

3. アサーションを具体的に書く

1
2
3
4
5
6
// 悪い例: 曖昧なアサーション
assert.ok(mockFn.mock.callCount() > 0)

// 良い例: 具体的なアサーション
assert.strictEqual(mockFn.mock.callCount(), 1)
assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ['expected', 'args'])

4. エラーケースも必ずテストする

 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
describe('エラーハンドリングのテスト', () => {
  test('API呼び出しが失敗した場合', async (t) => {
    const api = {
      fetchData: t.mock.fn(async () => {
        throw new Error('Network error')
      })
    }
    
    const service = createService(api)
    
    await assert.rejects(
      () => service.process(),
      { message: 'Network error' }
    )
  })
  
  test('タイムアウトした場合', async (t) => {
    t.mock.timers.enable({ apis: ['setTimeout'] })
    
    const api = {
      fetchData: t.mock.fn(() => new Promise(() => {})) // 永遠に解決しない
    }
    
    const service = createServiceWithTimeout(api, 5000)
    const promise = service.process()
    
    t.mock.timers.tick(5000)
    
    await assert.rejects(promise, { message: 'Request timeout' })
  })
})

まとめ

本記事では、node:testのモック機能を使ったテストダブルの実践方法を解説しました。

学んだこと

  1. mock.fn(): 関数モックの作成と呼び出し履歴の検証
  2. mock.method(): オブジェクトメソッドのスパイと実装差し替え
  3. mock.timers: setTimeout、setInterval、Dateのモック
  4. mock.module(): モジュール全体のモック(実験的機能)
  5. テストダブルの使い分け: スパイ、スタブ、モックの適切な選択

ポイント

  • 外部依存はテストダブルで分離し、高速で安定したテストを実現する
  • t.mockを使用することで、テスト終了時に自動クリーンアップされる
  • タイマーモックで時間に依存するロジックを即座にテストできる
  • 過度なモックを避け、外部境界のみをモックする

node:testのモック機能を活用することで、外部ライブラリに依存せず、堅牢で保守しやすいユニットテストを実装できます。

参考リンク