⚡️

VitestでMock, Testing Typesを使ってみよう!

2023/08/10に公開

TL;DR

前回の記事はテストコードで割とよく使う Snapshot の機能について、Vitest でどのように実装するかを解説しました。

https://zenn.dev/bs_kansai/articles/943c0c015ed41b

今回は一歩進んで、実践でわりとよく使うことになる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 することを考えます。

Cart.ts
export const cart = {
    getApples: (appleNums: number) => appleNums,
    getTotalItemCount: (oranges: number, apples: number) => oranges + apples,
}
App.test.tsx
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 する際に利用することが想定されます。

App.test.tsx
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/routeruseRouterを呼び出した場合の挙動について定義しています。

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 は一旦黙らせています)

Test.ts
/* 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がレスポンスされていることがわかります。

App.test.tsx
/* 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では引数の型について検証します。

Test.ts
export const getLabel = (name: string, age: number) => {
    return `名前は${name}で年齢は${age}歳です`
}

assertTypeまたはexpectTypeOfを用いて引数の型が期待しているものかどうかを検証することが可能です。

App.test.tsx
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 ユーザーはぜひご参加ください!!

https://serverside-kt.connpass.com/

また関西在住のソフトウェア開発者を中心に、関西エンジニアコミュニティを一緒に盛り上げてくださる方を募集しています。

よろしければ Conpass からメンバー登録よろしくお願いいたします。

https://blessingsoftware.connpass.com/

Discussion