VitestでMock, Testing Typesを使ってみよう!
TL;DR
前回の記事はテストコードで割とよく使う Snapshot の機能について、Vitest でどのように実装するかを解説しました。
今回は一歩進んで、実践でわりとよく使うことになるMock
,Testing Types
の使い方を紹介します。
Mock
Mock とは内部または外部サービスのフェイクモジュールみたいなもので、実際のモジュールに代わってテストコード内で定義した振る舞いをしてくれるものです。
一般的に副作用がある関数やクラスを Mock にすることで、常に一定の結果を返してくれるようにします。
例としては、日時操作をしたり、外部 API や DB からデータを取得する場合です。
その状態に対して、値や画面表示が適切に実行されているかを検証したいという場面で役立ちます。
Date
まずは日付を Mock する方法について説明します。
ソースは公式から拝借して、一部説明がしやすいように修正しています。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sut = {
purchase: () => {
const businessHours = [9, 17]
const currentHour = new Date().getHours()
const [open, close] = businessHours
if (currentHour > open && currentHour < close)
return { message: 'Success' }
return { message: 'Error' }
}
}
describe(`購入フローのテスト`, () => {
beforeEach(() => {
// Mockを使う
vi.useFakeTimers()
})
afterEach(() => {
// Mockではなくリアルタイムを使う
vi.useRealTimers()
})
it(`営業時間内の場合、purchase()はErrorを返す`, () => {
const date = new Date(2000, 1, 1, 13)
vi.setSystemTime(date)
expect(sut.purchase()).toEqual({ message: 'Success' })
})
it(`営業時間外の場合、purchase()はErrorを返す`, () => {
const date = new Date(2000, 1, 1, 19)
vi.setSystemTime(date)
expect(sut.purchase()).toEqual({ message: 'Error' })
})
})
beforeEach
,afterEach
で Mock, UnMock を切り替えています。
あとはsetSystemTime
に時間をセットすることで、Mock の日時を変更しているというわけです。
Functions
Vitest では関数をテストするためのアプローチとして、vi.spyOn
,vi.fn
の主に二つを提供しています。
この節ではvi.spyOn
,vi.fn
それぞれの用途について見ていきたいと思います。
vi.spyOn
vi.spyOn
はオブジェクトが持つメソッドの処理を置き換えるために利用できます。
例えば、以下の export されたオブジェクトに対して関数を mock することを考えます。
export const cart = {
getApples: (appleNums: number) => appleNums,
getTotalItemCount: (oranges: number, apples: number) => oranges + apples,
}
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cart } from "./test"
describe(`Spyを使ったCartのテスト`, () => {
afterEach(() => {
vi.restoreAllMocks()
})
//オブジェクトのメソッドもしくはsetter/getterを偽装する
it(`spyを使ってリンゴの戻り値を偽造する`, () => {
//! これにより、cart.getApples()は常に1を返すようになる
const appleNums = 2
const spy = vi.spyOn(cart, 'getApples').mockImplementation(() => 1)
expect(cart.getApples(appleNums)).toBe(1)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveReturnedWith(1)
})
it(`spyを使ってカートの合計数の戻り値を偽造する`, () => {
//! これにより、cart.getTotalItemCount()は常に1を返すようになる
const spy = vi.spyOn(cart, 'getTotalItemCount').mockImplementation(() => 1)
expect(cart.getTotalItemCount(2, 100)).toBe(1)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveReturnedWith(1)
})
})
使い方は簡単で、vi.spyOn(オブジェクト, 'メソッド名').mockImplementation(偽装した関数)
を作ります。
あとは、普通に mock した関数を呼び出すと、偽装した内容で処理を返すようになります。
getApples
は引数の数をそのまま返すので通常であれば実行後の期待値は2
ですが、サンプルコードではtoBe(1)
でテストがグリーンになっています。
またvi.spyOn
を使う上で重要なのは、監視可能な mock を作れることです。
toHaveBeenCalled()
で呼び出しされたかを確認し、toHaveReturnedWith(実行後の期待値)
で過去に呼び出された際の戻り値が期待するものであったか?を確認することができます。
vi.fn
vi.fn
は引数に渡されたコールバック関数などを mock する際に利用することが想定されます。
import { afterEach, describe, expect, it, vi } from 'vitest'
describe(`Spyを使ったCartのテスト`, () => {
afterEach(() => {
vi.restoreAllMocks()
})
it(`vi.fnの存在意義は監視可能なmock関数を作れること`, () => {
//! ただのダミー関数を作るだけでよいのであれば、mockとmock2に違いはない
const mock = vi.fn(() => 3)
const mock2 = () => 3
expect(mock()).toBe(mock2())
//! ただし、呼び出しの回数やこれまでに呼び出された値の検証までしたいといった監視を行いたいならば、
//! vi.fnを使う必要がある
//! 引数として渡すことを想定した場合、渡した関数内部で関数が書き換えられていないか(副作用がないか)を検証するなどが考えられる
expect(mock).toHaveBeenCalled()
expect(mock).toHaveReturnedWith(3)
//! 2回目の呼び出し
mock.mockReturnValueOnce(5)
mock()
//! n回目の呼び出し時に取得した値が期待値と一致するかどうかを検証する
expect(mock).toHaveNthReturnedWith(1, 3)
expect(mock).toHaveNthReturnedWith(2, 5)
})
})
ここで、vi.fn
の用途についてvi.fn(() => 3)
と() => 3
と書いた場合の違いについて考えておきたいと思います。
ただ mock された関数を実行するだけであれば、どちらの書き方をしても違いはありません。
しかし、vi.spyOn
でも述べましたが mock で重要なのは、監視可能な mock を作れることです。
引数として渡すことを想定した場合、渡した関数内部で関数が書き換えられていないか(副作用がないか)を検証するなどが考えられます。
このためvi.fn(() => 3)
を使うと関数が呼び出されるたびに、その呼び出し引数・戻り値・インスタンスが保存されます。
これらの保存された値を利用して、toHaveNthReturnedWith
などを利用し**n 回目の関数の呼び出し結果がどうだったか?**などの検証を行うことが可能です。
Module
vi.mock
を利用することで、コンポーネント内部で利用されている import の中身を偽装したりといったことが可能になります。
例えば以下では、next/router
のuseRouter
を呼び出した場合の挙動について定義しています。
vi.mock('next/router', () => ({
useRouter: vi.fn().mockReturnValue({
pathname: '/',
query: { language: 'javascript' },
}),
}))
Requests
Vitest では RestAPI や GraphQL 呼び出しの mock としてmsw
の利用を推奨しています。
npm install -D msw
例えば、とあるコンポーネント内部で以下のような RestAPI を実行する関数が呼ばれていたとします。
(本題とは関係ないので、es-lint は一旦黙らせています)
/* eslint-disable @typescript-eslint/no-unsafe-return */
export const getWeatherInfoOfKobe = async () => {
return await fetch("https://weather.tsukumijima.net/api/forecast/city/280010")
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
return await response.json()
})
}
この関数では兵庫県神戸市の気象情報を取得しています。
レスポンスは以下のようになっています。
ですが、例えば publicTime などはテストの実行タイミングによっては値が常に同じとはいえません。
ただ、ここで関心があることというのは、RestAPI を叩けているかどうか?ということだけで、戻り値については本番と同様の形式の値さえレスポンスされていてば値の実態についてはなんでも良いわけです。
そこでmsw
のような RestAPI や GraphQL の mock を利用することで、API の呼び出しについてのみ検証しつつ、常に同一の結果がレスポンスされるようにすることが可能です。
以下のテストを実行することで、自分が偽装したweatherInfo
がレスポンスされていることがわかります。
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import { getWeatherInfoOfKobe } from "./test"
const weatherInfo = {
title: "兵庫県 神戸 の天気★",
publicTime: "2023-08-08T18:00:00+09:00",
}
const restHandlers = [
rest.get('https://weather.tsukumijima.net/api/forecast/city/280010', (_, res, ctx) => {
return res(ctx.status(200), ctx.json(weatherInfo))
}),
]
const server = setupServer(...restHandlers)
describe(`mswを使ったAPI mockのテスト`, () => {
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
// Close server after all tests
afterAll(() => server.close())
// Reset handlers after each test `important for test isolation`
afterEach(() => {
server.resetHandlers()
vi.restoreAllMocks()
})
it(`mswを使ってAPIの戻り値を偽装する`, async () => {
const result = await getWeatherInfoOfKobe().catch((e) => {
console.log(e)
})
console.log(result)
expect(result.title).toBe("兵庫県 神戸 の天気★")
})
})
Testing Types
toBe
やこれまでにみてきたspyOn
,vi.fn
では戻り値について検証を行ってきましたが、Testing Types
では引数の型について検証します。
export const getLabel = (name: string, age: number) => {
return `名前は${name}で年齢は${age}歳です`
}
assertType
またはexpectTypeOf
を用いて引数の型が期待しているものかどうかを検証することが可能です。
import { afterEach, describe, expectTypeOf, assertType, it, vi } from 'vitest'
import { getLabel } from "./test"
describe(`Testing Types`, () => {
afterEach(() => {
vi.restoreAllMocks()
})
it(`関数であるかどうかと、引数の型を検証する`, () => {
//! 関数であるかどうか
expectTypeOf(getLabel).toBeFunction()
//! 引数の型を検証する
expectTypeOf(getLabel).parameter(0).toMatchTypeOf<string>()
expectTypeOf(getLabel).parameter(1).toMatchTypeOf<number>()
//! まとめて検証
assertType(getLabel('名前', 1))
})
})
おわりに
次回で一旦 Vitest シリーズは最後になるかと思います。
次回記事は Vitest UI について書く予定です。
メンバー募集中!
サーバーサイド Kotlin コミュニティを作りました!
Kotlin ユーザーはぜひご参加ください!!
また関西在住のソフトウェア開発者を中心に、関西エンジニアコミュニティを一緒に盛り上げてくださる方を募集しています。
よろしければ Conpass からメンバー登録よろしくお願いいたします。
Discussion