Vitestでモックを使ってテストを書く時の備忘録

2024-06-28

はじめに

こんにちは。Belong でエンジニアをしている ryo です。
最近プロジェクトで Vitest を使ってテストを書く機会があり、その際にモックする方法について調べたため、自分自身への備忘録としてまとめました。

Vitest について詳細を知りたい方は公式ドキュメントを参照してください。

環境

  • Vitest: 1.6.0

名前付きエクスポートされた関数をモックする

例えば以下のような calc.ts で名前付きエクスポートしている関数 add() をモックしたい場合、

// calc.ts
const add = (a: number, b: number) => a + b

const sub = (a: number, b: number): number => a - b

export { add, sub }

以下のように vi.mock() を使う事でモックできます。

// utils.test.ts
import { expect, test, vi } from 'vitest'
import { add, sub } from './calc'

test('calc', async () => {
  vi.mock('./calc.ts', async (importOriginal) => {
    const mod = await importOriginal<typeof import('./calc.ts')>()
    return {
      ...mod,
      add: () => 100,
    }
  })

  expect(add(2, 1)).toBe(100) // passed (モックできている)
  expect(sub(2, 1)).toBe(1) // passed
})

関数内部で利用している関数をモックする

utils.tscalc.ts をインポートし、getResult() 内で add() を利用している場合でも vi.mock() でモックできます。
この方法は既存の関数を内部で利用するような新しい関数のテストを書く際に便利です。

// utils.ts
import { add } from './calc'

const getResult = () => add(1, 3)

export { getResult }
// utils.test.ts
import { expect, test, vi } from 'vitest'
import { getResult } from './utils'

test('getResult', async () => {
  vi.mock('./calc.ts', async (importOriginal) => {
    const mod = await importOriginal<typeof import('./calc.ts')>()
    return {
      ...mod,
      add: () => 100,
    }
  })

  expect(getResult()).toBe(100) // passed(add() をモックした値をgetResult()が返している)
})

なぜモックが可能なのか

Substitutes all imported modules from provided path with another module. You can use configured Vite aliases inside a path. とドキュメントに記載のある通り、 実行中は全てのモジュール呼び出しをモックしたモジュールに置き換えることができます。
(おそらく Node.js のモジュールのキャッシュを利用して引数として渡された path でモジュールシステム上にキャッシュをつくっているんだと思っていますが、詳細はまだ追えていません...)

また、vi.mock() の呼び出しは hoist されてインポート文より前に実行されるため、どこで呼び出してもモックが適用されます。
これらの Vitest の内部的な機能によって、utils.test.ts の実行中は add() が常に 100 を返すようにモック出来ています。

ちなみに、同じモジュール内で定義されている関数を別の関数のためにモックする事は Vitest では出来ません。(公式ドキュメントにも記載があります)
なぜなら Vitest はモジュールを外部からモックする仕組みだからです。同じモジュール内で直接関数を参照している場合は影響を与えられません。
どうしてもモックしたい場合は、モジュールを分割した上で対象の関数をモックする等の必要があります。

名前付きエクスポートされたオブジェクトのメソッドをモックする

vi.spyOn() を使う事で、以下のように実現できます。

// calc.ts
const add = (a: number, b: number) => a + b

const sub = (a: number, b: number): number => {
  return a - b
}

const calc = {
  add,
  sub,
}

export { calc }
// calc.test.ts
import { expect, test, vi } from 'vitest'
import { calc } from './calc'

test('add', async () => {
  vi.spyOn(calc, 'add').mockReturnValue(100)

  expect(calc.add(2, 1)).toBe(100) // passed(モックできている)
})

calc.add をスパイして、呼び出された時に 100 を返すようにモックしています。vi.mock() と同様に一度定義するとその後もモックした値を返すため、別のモジュールの関数内部で利用されている場合でもモックが適用されます。

同じテストファイルの各テストケースで異なるモックをしたい場合

以下のように vi.mock()vi.mocked() を組み合わせて実現できます。

import { describe, expect, test, vi } from 'vitest'
import { getResult } from './utils'

vi.mock('./utils.ts')
describe('getResult', () => {
  test('should return 10', async () => {
    vi.mocked(getResult).mockReturnValue(10)
    expect(getResult()).toBe(10) // passed
  })
  test('should return 100', async () => {
    vi.mocked(getResult).mockReturnValue(100)
    expect(getResult()).toBe(100) // passed
  })
})

vi.mock() に factory を渡さずにモックして適宜 vi.mocked() でモックしたい関数を指定することで、異なる値を適用できます。

ちなみに、以下のように vi.mock() を複数回呼び出してモックしようとすると、最後に呼び出した vi.mock() のモックが全てに適用されるため、最初に定義したテストは落ちてしまうため注意が必要です。

import { describe, expect, test, vi } from 'vitest'
import { getResult } from './utils'

describe('getResult', () => {
  test('should return 10', async () => {
    vi.mock('./utils.ts', async (importOriginal) => {
      const mod = await importOriginal<typeof import('./utils.ts')>()
      return {
        ...mod,
        getResult: () => 10,
      }
    })
  })
  expect(getResult()).toBe(10) // failed

  test('should return 100', async () => {
    vi.mock('./utils.ts', async (importOriginal) => {
      const mod = await importOriginal<typeof import('./utils.ts')>()
      return {
        ...mod,
        getResult: () => 100,
      }
    })
    expect(getResult()).toBe(100) // passed
  })
})

なぜ最後に定義したモックが優先されるのかは私が公式のキュメントを読んだ限りでは明言されてはいないように見えましたが、コードを読むと pendingIds に push する際 pathid として渡しているため、この辺りに関係がありそうかな、とは思っています。長くなってしまいそうなのでまた別の機会に調べてみようと思います。

まとめ

今回は Vitest でテストコードを書く際のモックの方法についてまとめました、ご参考になれば幸いです。

また、弊社 Belong は一緒に働くフロントエンド エンジニアを募集しています。
興味がある方は https://entrancebook.belonginc.dev/ をご覧いただけると幸いです。