VitestでMockを使うための逆引きレシピ集
はじめに
最近Nest.js×Vitestを使ったプロジェクトのテストコードを書く機会がありました。
バックエンドをTypeScriptで書くのが初めてで、モックの書き方がさっぱりわからず困ってしまいました。
そこで勉強のために、普段テストを書くときに使うKotlinのモックライブラリ・Mockk
でよく使うメソッドを「Vitestで使うには?」という視点で、いろいろと使い方を身につけようという魂胆です。
レシピ集
ということで見るべきものはVitestのモックのページなのですが、いかんせんコードがあまりにもサンプルすぎてイメージが湧きません。
またドキュメントらしき正引きになっていますが、最初のうちに正引きでドキュメントを見る人間なんていません。そのモックメソッドが最初から何をやってるのか知ってる初心者などいないからです。
ということで、この記事では逆引きでサンプルを列挙していきます。コードも本番プロダクトで使うときに若干イメージしやすくなっている…はずです。
同期メソッドの戻り値を単純にモックする
なにはともあれベーシックなモックができないことには始まりません。
単純にメソッドの振る舞いを変えるためには、mockReturnValue
を使います。
import { describe, test, expect, vi } from 'vitest';
class Integer {
constructor(public value: number) {}
getNumber() {
return 0; //! 例えば常に0を返すプロダクションコードだとして
}
}
class Adder {
constructor(public left: Integer, public right: Integer) {}
add() {
return this.left.getNumber() + this.right.getNumber();
}
}
describe('Adder', () => {
test('あたりまえだけど、0同士を足すので0', () => {
// 準備
const left = new Integer(1);
const right = new Integer(2);
// 実行
const sut = new Adder(left, right);
const result = sut.add();
// 検証
expect(0).toBe(result);
});
test('0じゃない数字を返すようにするには?', () => {
// 準備
const left = new Integer(1);
const right = new Integer(2);
left.getNumber = vi.fn().mockReturnValue(1);
right.getNumber = vi.fn().mockReturnValue(2);
// 実行
const sut = new Adder(left, right);
const result = sut.add();
// 検証
expect(3).toBe(result);
});
});
非同期メソッド/Resolvedの戻り値モックする
同様に、非同期メソッドの戻り値をモックするにはmockResolvedValue
を使います。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
} else {
return "天気の取得に失敗しました";
}
}
}
describe('Weather', () => {
test('神戸市の天気の取得をモックする', async () => {
// 準備
const weather = new Weather();
const mockedReturnValue = {
telop: '晴れ',
}
weather.getWeather = vi.fn().mockResolvedValue(mockedReturnValue)
// 実行
const sut = new WeatherKobe(weather);
const result = await sut.getWeather();
// 検証
expect(mockedReturnValue).toBe(result);
});
});
非同期メソッド/Rejectedの戻り値モックする
今度は非同期メソッド/Resolved
の逆で、非同期メソッドがエラーとなりRejected
された場合をモックする動きです。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
try{
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
}
}catch(e){
return "天気の取得に失敗しました";
}
}
}
describe('Weather', () => {
test('神戸市の天気の取得失敗の場合をモックする', async () => {
// 準備
const weather = new Weather();
weather.getWeather = vi.fn().mockRejectedValue(new Error("何かしらのエラー"))
// 実行
const sut = new WeatherKobe(weather);
const result = await sut.getWeather();
// 検証
expect("天気の取得に失敗しました").toBe(result);
});
});
連続して呼び出した際の戻り値をモックする
これは例えば、新たに付与する商品明細のIDを一気にレスポンスする場合などです。
import { describe, test, expect, vi } from 'vitest';
class ItemDetailIdGenerator {
generateId() {
return Math.random(); //! 本来であればDBなどへ問い合わせている
}
}
class ItemDetailService {
constructor(private itemDetailIdGenerator: ItemDetailIdGenerator) {}
async createItemDetailIds(howMany: number) {
const ids: number[] = [];
for (let i = 0; i < howMany; i++) {
const id = this.itemDetailIdGenerator.generateId();
ids.push(id);
}
return ids;
}
}
describe('ItemDetailService', () => {
test('3つの商品明細IDを発行したとする', async () => {
// 準備
const itemDetailIdGenerator = new ItemDetailIdGenerator();
itemDetailIdGenerator.generateId
= vi.fn().mockImplementationOnce(() => 1).mockImplementationOnce(() => 2).mockImplementationOnce(() => 3);
// 実行
const sut = new ItemDetailService(itemDetailIdGenerator);
const result = await sut.createItemDetailIds(3);
expect([1, 2, 3]).toEqual(result);
});
});
また、呼び出しに対して常に同じ値を返すようにモックしたい場合はmockImplementation
を使います。
import { describe, test, expect, vi } from 'vitest';
class ItemDetailIdGenerator {
generateId() {
return Math.random(); //! 本来であればDBなどへ問い合わせている
}
}
class ItemDetailService {
constructor(private itemDetailIdGenerator: ItemDetailIdGenerator) {}
async createItemDetailIds(howMany: number) {
const ids: number[] = [];
for (let i = 0; i < howMany; i++) {
const id = this.itemDetailIdGenerator.generateId();
ids.push(id);
}
return ids;
}
}
describe('ItemDetailService', () => {
test('3つの商品明細IDを発行したとする', async () => {
// 準備
const itemDetailIdGenerator = new ItemDetailIdGenerator();
itemDetailIdGenerator.generateId
= vi.fn().mockImplementation(() => 1);
// 実行
const sut = new ItemDetailService(itemDetailIdGenerator);
const result = await sut.createItemDetailIds(3);
expect([1, 1, 1]).toEqual(result); //? IDとは…?
});
});
現在の時刻をモックする
時刻をモックするためには、テストケースの実行前にvi.useFakeTimers()
にて自分達の好きな時間を設定できるようにし、実行後に本来のタイムゾーンであるvi.useRealTimers()
に戻します。
import { describe, test, vi,beforeAll, afterAll, assert} from 'vitest';
class Cafe {
checkOpenCafe(){
const date = new Date()
const hours = date.getHours()
return 7 < hours && hours < 23
}
}
beforeAll(() => {
vi.useFakeTimers()
})
afterAll(() => {
vi.useRealTimers()
})
describe('checkOpenCafe', () => {
test('現在時刻が9時の場合を検証', () => {
// 準備
vi.setSystemTime(new Date('2099/01/01 09:00:00'))
// 実行
const sut = new Cafe()
const isOpenCafe = sut.checkOpenCafe()
// 検証
assert.isOk(isOpenCafe)
})
test('現在時刻が23時の場合を検証', () => {
// 準備
vi.setSystemTime(new Date('2099/01/01 23:00:00'))
// 実行
const sut = new Cafe()
const isCloseCafe = sut.checkOpenCafe()
// 検証
assert.isNotOk(isCloseCafe)
})
});
実行時のモックの引数を検証する
いくつかパターンがあります。まずvi.fn()
を別の変数に持っておき、最後に呼び出された値を検証するmock.lastCall
を使う方法です。
mock.lastCall
はvi.fn()
の戻り値のプロパティです。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
} else {
return "天気の取得に失敗しました";
}
}
}
describe('Weather', () => {
test('weather.getWeatherが神戸市のコードで呼ばれたかどうかを知る', async () => {
// 準備
const weatherMock = vi.fn()
const weather = new Weather();
const mockedReturnValue = {
telop: '晴れ',
}
weather.getWeather = weatherMock.mockResolvedValue(mockedReturnValue)
// 実行
const sut = new WeatherKobe(weather);
const result = await sut.getWeather();
// 検証
expect(mockedReturnValue).toBe(result);
expect(280010).toBe(weatherMock.mock.lastCall[0])
});
});
次に、mock.calls
を使う方法があります。これはvi.fn()
でモックした場合に呼び出しごとの引数がなんであったかを保持します。
そのため、二次元配列で呼び出し時の引数が取れます。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
} else {
return "天気の取得に失敗しました";
}
}
}
describe('Weather', () => {
test('weather.getWeatherが神戸市のコードで呼ばれたかどうかを知る', async () => {
// 準備
const weatherMock = vi.fn()
const weather = new Weather();
const mockedReturnValue = {
telop: '晴れ',
}
weather.getWeather = weatherMock.mockResolvedValue(mockedReturnValue)
// 実行
const sut = new WeatherKobe(weather);
const result = await sut.getWeather();
// 検証
expect(mockedReturnValue).toBe(result);
expect(280010).toBe(weatherMock.mock.calls[0][0])
});
});
最後に、アサーション側のメソッドtoHaveBeenCalledWith
を使う方法です。おそらくこちらが最もシンプルに記述できそうです。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
} else {
return "天気の取得に失敗しました";
}
}
}
describe('Weather', () => {
test('weather.getWeatherが神戸市のコードで呼ばれたかどうかを知る', async () => {
// 準備
const weatherMock = vi.fn()
const weather = new Weather();
const mockedReturnValue = {
telop: '晴れ',
}
weather.getWeather = weatherMock.mockResolvedValue(mockedReturnValue)
// 実行
const sut = new WeatherKobe(weather);
const result = await sut.getWeather();
// 検証
expect(mockedReturnValue).toBe(result);
expect(weather.getWeather).toHaveBeenCalledWith(280010);
});
});
例外を検証する
特定の例外がthrowされることを期待する場合は、toThrow
を使ってアサーションできます。
import { describe, test, expect, vi } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
}
throw data;
}
throwError() {
throw new Error("エラーが発生しました");
}
}
describe('Weather', () => {
test('非同期の場合', async () => {
// 準備
const weather = new Weather();
weather.getWeather = vi.fn().mockRejectedValue(new Error("何かしらのエラー"))
const sut = new WeatherKobe(weather);
// 検証
const expected = new Error("何かしらのエラー");
await expect(sut.getWeather()).rejects.toThrow(expected);
});
test('同期の場合', async () => {
// 準備
const weather = new Weather();
const sut = new WeatherKobe(weather);
// 検証
const expected = new Error("エラーが発生しました");
await expect(() => sut.throwError()).toThrow(expected);
});
});
逆に例外を吐かないことを期待するのであれば、doesNotThrow
を使って検証できます。
import { describe, test, vi, assert } from 'vitest';
import {axios} from 'axios';
class Weather {
async getWeather(cityCode: number) {
const res = await axios.get(`https://weather.tsukumijima.net/api/forecast/city/${cityCode}`);
return res.data;
}
}
class WeatherKobe {
private weather: Weather;
constructor(weather: Weather) {
this.weather = weather;
}
async getWeather() {
const data = await this.weather.getWeather(280010);
if(typeof data === 'object' && typeof data.telop !== 'undefined' && typeof data.telop === "string"){
return data;
}
throw new Error('Invalid data');
}
getWeatherIcon() {
return '☀️';
}
}
describe('Weather', () => {
test('非同期メソッドが例外を吐かないことを検証する', async () => {
// 準備
const weather = new Weather();
const mockedReturnValue = {
telop: '晴れ',
}
weather.getWeather = vi.fn().mockResolvedValue(mockedReturnValue)
const sut = new WeatherKobe(weather);
// 検証
assert.doesNotThrow(async () => await sut.getWeather());
});
test('同期メソッドが例外を吐かないことを検証する', async () => {
// 準備
const weather = new Weather();
const sut = new WeatherKobe(weather);
// 検証
assert.doesNotThrow(() => sut.getWeatherIcon());
});
});
おわりに
ぱっと思い浮かべたときによく使いそうなモックパターンを試しつつ、列挙してみました。
結果、vi.fn()
を使っておけばだいたいなんとかなりそうですね。
📢 Kobe.tsというTypeScriptコミュニティを主催しています
フロント・バックエンドに限らず、周辺知識も含めてTypeScriptの勉強会を主催しています。
毎朝オフラインでもくもくしたり、神戸を中心に関西でLTもしています。
盛り上がってる感を出していきたいので、良ければメンバーにだけでもなってください😣
Discussion