⚡️

VitestでMockを使うための逆引きレシピ集

2024/06/06に公開

はじめに

最近Nest.js×Vitestを使ったプロジェクトのテストコードを書く機会がありました。

バックエンドをTypeScriptで書くのが初めてで、モックの書き方がさっぱりわからず困ってしまいました。

そこで勉強のために、普段テストを書くときに使うKotlinのモックライブラリ・Mockkでよく使うメソッドを「Vitestで使うには?」という視点で、いろいろと使い方を身につけようという魂胆です。

レシピ集

ということで見るべきものはVitestのモックのページなのですが、いかんせんコードがあまりにもサンプルすぎてイメージが湧きません。

またドキュメントらしき正引きになっていますが、最初のうちに正引きでドキュメントを見る人間なんていません。そのモックメソッドが最初から何をやってるのか知ってる初心者などいないからです。

https://vitest.dev/api/mock.html#mockimplementation

ということで、この記事では逆引きでサンプルを列挙していきます。コードも本番プロダクトで使うときに若干イメージしやすくなっている…はずです。

同期メソッドの戻り値を単純にモックする

なにはともあれベーシックなモックができないことには始まりません。

単純にメソッドの振る舞いを変えるためには、mockReturnValueを使います。

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を使います。

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された場合をモックする動きです。

mockRejectedValueの使い方
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を使います。

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.lastCallvi.fn()の戻り値のプロパティです。

mock.lastCallを使う
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()でモックした場合に呼び出しごとの引数がなんであったかを保持します。

そのため、二次元配列で呼び出し時の引数が取れます。

mock.callsを使う
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を使う方法です。おそらくこちらが最もシンプルに記述できそうです。

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を使ってアサーションできます。

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を使って検証できます。

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もしています。

盛り上がってる感を出していきたいので、良ければメンバーにだけでもなってください😣

https://kobets.connpass.com/

Discussion