Zenn
💯

テストケースごとにvi.mockでモックした関数の返り値を変えたい

に公開

タイトルの通り、テストケースごとに vi.mock でモックした関数の返り値を変えたいです。

結果的にできたのですが、ググってもAIに聞いてもズバリの情報が得られませんでした。他に良いやり方があるかもしれませんが、とりあえずこれでできましたというメモになります。

前提

src
├── my-module.ts
└── sample.ts

こんな感じで各ファイルがあったとします。

my-module.ts
export const getMyValue = async () => {
  return 'myValue';
};
sample.ts
import { getMyValue } from './my-module';

export const getMyValueWrapper = async () => {
  const myValue = await getMyValue();
  return myValue;
};

今回は my-module.tsgetValue()vi.mock でモックしつつ sample.ts のテストを書きたいです。

先に完成系

先に完成系を出してしまうと、 vi.fnvi.hoisted を組み合わせるとできました。

sample.test.ts
import { test } from 'vitest';

import { getMyValueWrapper } from './sample';

const mockGetMyValue = vi.hoisted(() => {
  return vi.fn();
});

vi.mock('./my-module', () => {
  return {
    getMyValue: mockGetMyValue,
  };
});

beforeEach(() => {
  mockGetMyValue.mockClear();
});

test('foo', async () => {
  mockGetMyValue.mockResolvedValue('foo');
  expect(await getMyValueWrapper()).toBe('foo');
});

test('bar', async () => {
  mockGetMyValue.mockResolvedValue('bar');
  expect(await getMyValueWrapper()).toBe('bar');
});

test('baz', async () => {
  mockGetMyValue.mockResolvedValue('baz');
  expect(await getMyValueWrapper()).toBe('baz');
});

このように書くことでテストケースごとにモックした関数の返り値を変えることができました。

返り値のデフォルトを設定したい場合

返り値のデフォルトを設定したい場合は、 beforeEachmockGetMyValue.mockResolvedValue() を書くと良いです。

sample.test.ts
beforeEach(() => {
  mockGetMyValue.mockClear();

  // デフォルト設定
  mockGetMyValue.mockResolvedValue('default');
});

test('default', async () => {
  // テストケース内で値を設定しなくても `'default'` になる
  expect(await getMyValueWrapper()).toBe('default');
});

vi.mockとvi.hoistedと巻き上げについて

vi.mock は実行時にファイルの先頭に巻き上げられます。

なので次のように書いてもうまくいきません。

import { test } from 'vitest';

import { getMyValueWrapper } from './sample';

test('foo', async () => {
  vi.mock('./my-module', () => {
    return {
      getMyValue: async () => 'foo',
    };
  });
  expect(await getMyValueWrapper()).toBe('foo'); // `actual: 'baz'` になって失敗
});

test('bar', async () => {
  vi.mock('./my-module', () => {
    return {
      getMyValue: async () => 'bar',
    };
  });
  expect(await getMyValueWrapper()).toBe('bar'); // `actual: 'baz'` になって失敗
});

test('baz', async () => {
  vi.mock('./my-module', () => {
    return {
      getMyValue: async () => 'baz',
    };
  });
  expect(await getMyValueWrapper()).toBe('baz'); // これだけ成功する
});

最後のテスト以外は失敗します。なぜでしょうか?

テストファイルのトランスパイル後は次のようになっていると思われます。

トランスパイル後(イメージ)
// vi.mockはファイルの先頭に巻き上げられる
vi.mock('./my-module', () => {
  return {
    getMyValue: async () => 'foo',
  };
});
vi.mock('./my-module', () => {
  return {
    getMyValue: async () => 'bar',
  };
});
vi.mock('./my-module', () => {
  return {
    getMyValue: async () => 'baz',
  };
});

import { test } from 'vitest';

import { getMyValueWrapper } from './sample';

test('foo', async () => {
  expect(await getMyValueWrapper()).toBe('foo');
});

test('bar', async () => {
  expect(await getMyValueWrapper()).toBe('bar');
});

test('baz', async () => {
  expect(await getMyValueWrapper()).toBe('baz');
});

このため、最後の

vi.mock('./my-module', () => {
  return {
    getMyValue: async () => 'baz',
  };
});

だけが有効となり、すべてのテストで返り値が 'baz' になってしまいます。

そのため、開発メンバーのコードリーディング時に誤解が起きづらいよう vi.mock は基本的にファイルの先頭に近い場所かつトップレベルで書くのが良いでしょう。

また、 vi.hoisted を使うことでファイル先頭に巻き上げて変数を定義できます。

sample.test.ts
const mockGetMyValue = vi.hoisted(() => {
  return vi.fn();
});

vi.mock('./my-module', () => {
  return {
    getMyValue: mockGetMyValue,
  };
});

このようにすると、

トランスパイル後(イメージ)
// vi.mockより先頭に巻き上げて変数を定義できる
const mockGetMyValue = (() => vi.fn())();

vi.mock('./my-module', () => {
  return {
    getMyValue: mockGetMyValue,
  };
});

のようにトランスパイルされると思われます(そのはず・・・)。

ここまで来れば、テストケースごとに .mockResolvedValue() で返り値を設定できるようになります(async関数でない場合は .mockReturnValue() を使う)。

そのほか動かないコードの例

デフォルト値の設定で、最初次のようなコードを書いてうまくいかず、一瞬ハマりました。

sample.test.ts
const mockGetMyValue = vi.hoisted(() => {
  return vi.fn();
});

vi.mock('./my-module', () => {
  return {
    // デフォルトの返り値を設定?
    getMyValue: mockGetMyValue.mockResolvedValue('default'),
  };
});

beforeEach(() => {
  mockGetMyValue.mockClear();
});

test('default', async () => {
  expect(await getMyValueWrapper()).toBe('default'); // `actual: 'undefined'` で失敗
});

直感的にはいけると思ったんですが、返り値が undefined の関数になってしまうようでした。

console.log(mockGetMyValue.constructor.name); // => `Function`
console.log(mockGetMyValue.mockResolvedValue('default').constructor.name); // => `Function`

.mockResolvedValue()Function インスタンスを返すようなので、いける気がしたんですが 🤔 いずれにせよ元のモックインスタンスを使う必要があります(これ以上深追いはできていません)。

同じハマり方をする人はいないかもしれませんが、一応紹介でした。

以上です。トランスパイル後のコードはドキュメントの記載から想像で書いたので、理解に誤りがあればご指摘ください。

参考

まずは公式。

https://vitest.dev/api/vi.html

https://vitest.dev/guide/mocking

そしてこちらの記事が大いに理解の手がかりになりました。感謝。

https://zenn.dev/you_5805/articles/vitest-mock-hoisting

Discussion

ログインするとコメントできます