Vitestでのモックの使い方を調べてみた

はじめに

単体テストを書く際、外部依存関係をモックすることは避けられない作業です。特にNext.jsやVue、Reactなどのモダンなフロントエンドフレームワークを使用したプロジェクトでは、コンポーネントやサービスの独立したテストを行うためにモックが必須となります。

Vitestは、Viteをベースにした高速なJavaScriptテストフレームワークで、Jestと互換性のあるAPIを提供しています。今回はVitestでのモックの使い方について、実際のコード例を交えながら詳しく解説します。

Vitestのモックの基本概念

Vitestでは、viオブジェクトを通じてモックとスパイの機能が提供されています。基本的なモックの種類には以下のようなものがあります:

  1. 関数モック (vi.fn())
  2. モジュールモック (vi.mock())
  3. スパイ (vi.spyOn())

それぞれの使い方を見ていきましょう。

1. 関数モック (vi.fn())

vi.fn()は最も基本的なモックで、任意の関数をモックするために使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { vi, expect, test } from 'vitest'

test('基本的な関数モック', () => {
  // モック関数の作成
  const mockFn = vi.fn()
  
  // モック関数の呼び出し
  mockFn()
  mockFn('引数あり')
  
  // 検証
  expect(mockFn).toHaveBeenCalled()
  expect(mockFn).toHaveBeenCalledTimes(2)
  expect(mockFn).toHaveBeenCalledWith('引数あり')
})

戻り値の設定

モック関数の戻り値を設定するには、いくつかの方法があります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 単一の値を返す
const mockReturnValue = vi.fn().mockReturnValue('固定値')

// 一度だけ特定の値を返す
const mockReturnValueOnce = vi.fn()
  .mockReturnValueOnce('一回目')
  .mockReturnValueOnce('二回目')
  .mockReturnValue('それ以降')

// 実装をカスタマイズする
const mockImplementation = vi.fn().mockImplementation((arg) => {
  return `処理した結果: ${arg}`
})

2. モジュールモック (vi.mock())

vi.mock()は、モジュール全体をモックするために使用します。これは特に外部ライブラリや他のファイルからインポートされる関数やクラスをモックする場合に便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { vi, expect, test } from 'vitest'

// モジュールをモック
vi.mock('./myModule', () => {
  return {
    someFunction: vi.fn().mockReturnValue('モック値'),
    someObject: {
      method: vi.fn().mockReturnValue('モックメソッド')
    }
  }
})

// モックされたモジュールをインポート
import { someFunction, someObject } from './myModule'

test('モジュールモック', () => {
  const result = someFunction()
  expect(result).toBe('モック値')
  
  const methodResult = someObject.method()
  expect(methodResult).toBe('モックメソッド')
})

実際のモジュールの一部を使用する

実際のモジュールの一部のみをモックし、他の部分は実際の実装を使用したい場合:

1
2
3
4
5
6
7
8
vi.mock('./myModule', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    // 特定の関数だけをモック
    someFunction: vi.fn().mockReturnValue('モック値')
  }
})

3. スパイ (vi.spyOn())

vi.spyOn()は、既存のオブジェクトのメソッドを監視(スパイ)するために使用します。単純に呼び出しを追跡するだけでなく、必要に応じて実装を置き換えることもできます。

 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
import { vi, expect, test } from 'vitest'

const user = {
  getName: () => 'Original Name',
  getAge: () => 25
}

test('スパイの基本', () => {
  // メソッドをスパイする
  const getSpy = vi.spyOn(user, 'getName')
  
  // 元の実装が呼ばれる
  const name = user.getName()
  
  expect(name).toBe('Original Name')
  expect(getSpy).toHaveBeenCalled()
})

test('スパイ + モック実装', () => {
  // スパイしながら実装を置き換える
  const getSpy = vi.spyOn(user, 'getName').mockReturnValue('Mocked Name')
  
  const name = user.getName()
  
  expect(name).toBe('Mocked Name')
  expect(getSpy).toHaveBeenCalled()
})

非同期コードのモック

非同期関数のモックも同様に簡単に行えます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Promise 成功のモック
const successMock = vi.fn().mockResolvedValue({ data: 'success' })

// Promise 失敗のモック
const failureMock = vi.fn().mockRejectedValue(new Error('Failed'))

// 非同期関数のテスト
test('非同期関数のモック', async () => {
  const result = await successMock()
  expect(result).toEqual({ data: 'success' })
  
  await expect(failureMock()).rejects.toThrow('Failed')
})

テスト間でのモックのリセット

テスト間の独立性を保つために、モックをリセットする方法がいくつか用意されています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 全てのモックをリセット
beforeEach(() => {
  vi.resetAllMocks() // 呼び出し情報をリセットするが、実装はそのまま
})

// あるいは
beforeEach(() => {
  vi.clearAllMocks() // 呼び出し情報のみをクリア
})

// 実装も含めて完全にリセット
beforeEach(() => {
  vi.restoreAllMocks() // スパイの場合、元の実装に戻す
})

実践例:外部APIをモックするテスト

Googleスプレッドシート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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// waitlist.ts
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { JWT } from 'google-auth-library'

export async function submitWaitlistForm(formData) {
  // バリデーション
  if (!formData.name) {
    return { error: '名前は必須です' }
  }
  
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
    return { error: '有効なメールアドレスを入力してください' }
  }
  
  if (!formData.teamSize) {
    return { error: 'チームサイズは必須です' }
  }

  try {
    // Google Sheets APIの認証
    const serviceAccountAuth = new JWT({
      email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
      key: process.env.GOOGLE_PRIVATE_KEY,
      scopes: ['https://www.googleapis.com/auth/spreadsheets'],
    })

    const doc = new GoogleSpreadsheet(
      process.env.GOOGLE_SHEET_ID,
      serviceAccountAuth
    )

    await doc.loadInfo()
    const sheet = doc.sheetsByIndex[0]
    
    // データを追加
    await sheet.addRow({
      name: formData.name,
      email: formData.email,
      company: formData.company,
      teamSize: formData.teamSize,
      message: formData.message,
    })

    return { success: true }
  } catch (error) {
    console.error('Error submitting form:', error)
    return { error: 'フォームの送信中にエラーが発生しました' }
  }
}

このような関数をテストするときは、外部依存関係(Googleスプレッドシート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
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// waitlist.test.ts
import { submitWaitlistForm } from './waitlist'
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { JWT } from 'google-auth-library'
import { vi, expect, describe, it, beforeEach } from 'vitest'

// モジュールをモック
vi.mock('google-spreadsheet', () => {
  return {
    GoogleSpreadsheet: vi.fn().mockImplementation(() => {
      return {
        loadInfo: vi.fn().mockResolvedValue(undefined),
        sheetsByIndex: [
          {
            addRow: vi.fn().mockResolvedValue(undefined),
          },
        ],
      }
    }),
  }
})

vi.mock('google-auth-library', () => {
  return {
    JWT: vi.fn().mockImplementation(() => {
      return {}
    }),
  }
})

describe('submitWaitlistForm', () => {
  // 各テスト前にモックをクリア
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('正常なリクエストを処理する', async () => {
    const formData = {
      name: 'テスト太郎',
      email: '[email protected]',
      company: 'テスト株式会社',
      teamSize: '2-5',
      message: 'テストメッセージ',
    }

    // モックの実装を直接参照できるように変数に保存
    const mockAddRow = vi.fn().mockResolvedValue(undefined)
    const mockLoadInfo = vi.fn().mockResolvedValue(undefined)
    
    const mockInstance = {
      loadInfo: mockLoadInfo,
      sheetsByIndex: [
        {
          addRow: mockAddRow,
        },
      ],
    }
    
    // GoogleSpreadsheetモックの実装を書き換え
    GoogleSpreadsheet.mockImplementation(() => mockInstance)

    const result = await submitWaitlistForm(formData)

    // 結果の検証
    expect(result).toEqual({ success: true })
    
    // モックが正しく呼び出されたことを検証
    expect(GoogleSpreadsheet).toHaveBeenCalled()
    expect(mockLoadInfo).toHaveBeenCalled()
    expect(mockAddRow).toHaveBeenCalledWith(
      expect.objectContaining({
        name: 'テスト太郎',
        email: '[email protected]',
        company: 'テスト株式会社',
        teamSize: '2-5',
        message: 'テストメッセージ',
      })
    )
  })

  it('名前が欠けている場合はエラーを返す', async () => {
    const formData = {
      name: '',
      email: '[email protected]',
      company: '',
      teamSize: '2-5',
      message: '',
    }
    
    const result = await submitWaitlistForm(formData)
    expect(result).toEqual({ error: '名前は必須です' })
  })

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

モックのよくある問題と解決策

問題1: 新しく作成したインスタンスのメソッドをテストできない

次のようなコードはエラーになります:

1
2
3
const mockJWT = new JWT()
const mockDoc = new GoogleSpreadsheet('', mockJWT)
expect(mockDoc.loadInfo).toHaveBeenCalled() // エラー!

解決策: テスト内で新しいインスタンスを作るのではなく、モック実装への参照を直接保持し、それをテストに使用します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const mockLoadInfo = vi.fn().mockResolvedValue(undefined)
const mockInstance = {
  loadInfo: mockLoadInfo,
  // ...
}
GoogleSpreadsheet.mockImplementation(() => mockInstance)

// 実際の関数を呼び出し
await myFunction()

// 直接モック関数を検証
expect(mockLoadInfo).toHaveBeenCalled() // 成功!

問題2: パス名が一致しないと正しくモックされない

1
2
vi.mock('./myModule') // ここでのパスが...
import { something } from '../utils/myModule' // ここと一致しない

解決策: 常にインポートと同じパスを使用します:

1
2
vi.mock('../utils/myModule')
import { something } from '../utils/myModule'

問題3: hoistingの問題

vi.mock()はファイルの先頭に自動的にホイストされるため、以下のコードは想定通りに動作しません:

1
2
3
4
5
6
const value = 'テスト'
vi.mock('./myModule', () => {
  return {
    someFunction: vi.fn().mockReturnValue(value) // valueはundefined
  }
})

解決策: ファクトリ関数内でローカル変数を使用します:

1
2
3
4
5
6
7
const value = 'テスト'
vi.mock('./myModule', () => {
  const localValue = 'テスト' // ここで再定義
  return {
    someFunction: vi.fn().mockReturnValue(localValue)
  }
})

まとめ

Vitestは、Jestに似た使いやすいモックAPIを提供しつつ、Viteの高速なビルドシステムを活用した素晴らしいテストフレームワークです。

モックを使いこなすポイントをまとめると:

  1. 単純な関数モックには vi.fn() を使う
  2. モジュール全体をモックするには vi.mock() を使う
  3. 既存のオブジェクトを監視するには vi.spyOn() を使う
  4. テスト間の独立性を保つために vi.clearAllMocks()vi.restoreAllMocks() を使う
  5. モックしたオブジェクトの参照を保持し、直接それを検証する

これらの手法を組み合わせることで、複雑なコードでも効率的にテストを書くことができます。テストコードがきれいに保たれていると、リファクタリングやバグの検出も容易になり、プロジェクトの品質向上につながります。

Vitestでのテスト作成を楽しんでください!

カテゴリ

comments powered by Disqus