はじめに#
ユニットテストを書く際、外部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を使うと、setTimeout、setInterval、Dateをモックして、時間に依存するテストを即座に実行できます。
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のモック機能を使ったテストダブルの実践方法を解説しました。
学んだこと#
- mock.fn(): 関数モックの作成と呼び出し履歴の検証
- mock.method(): オブジェクトメソッドのスパイと実装差し替え
- mock.timers: setTimeout、setInterval、Dateのモック
- mock.module(): モジュール全体のモック(実験的機能)
- テストダブルの使い分け: スパイ、スタブ、モックの適切な選択
ポイント#
- 外部依存はテストダブルで分離し、高速で安定したテストを実現する
t.mockを使用することで、テスト終了時に自動クリーンアップされる
- タイマーモックで時間に依存するロジックを即座にテストできる
- 過度なモックを避け、外部境界のみをモックする
node:testのモック機能を活用することで、外部ライブラリに依存せず、堅牢で保守しやすいユニットテストを実装できます。
参考リンク#