Vitest の mockClear / mockReset / mockRestore の違いについて
はじめに
こんにちは!レバウェル開発部アドベントカレンダー18日目です。
普段、スカウトチームではテストランナーに Vitest を使っています。今回は、Vitestのモックをクリアする方法について紹介します!
Vitest のモッククリア
公式ドキュメントを読むと、モックをクリアする方法は一見下記の 3 通りあるように見えます。
しかし、一度読んだだけでは、それぞれのメソッドをどの用途で呼び出せばいいのかわからなかったので調べてみました。
この記事で扱うサンプルプログラム
説明に入る前にこの記事で扱うサンプルプログラムについて説明します。
有効ユーザー数を取得して、整列して返却するプログラムを想定しています。ここでいう有効ユーザーとは age フィールドが undefined でないユーザーのことを指します。また、並び替えは年齢の低い順で行います。
export type User = {
name: string;
age: number | undefined;
}[];
export const getValidUsers = () => {
// as User | null 型を返す予定
const users = getUsers();
if (!users) {
return [];
}
const validUser = users
.filter((user) => user.age !== undefined)
.sort((a, b) => a.age! - b.age!);
return validUser;
};
しかし、上記プログラムで参照しているgetUsers
は未実装関数でテストではこのgetUsers
をモックすることにします。
export const getUsers = () => {
return "未実装です";
};
モッククリアするメソッドの違い
mockClear
公式ドキュメントによると下記のように記載があります。
()内は翻訳した文章です。
Clears all information about every call. After calling it, all properties on .mock will return to their initial state. This method does not reset implementations. It is useful for cleaning up mocks between different assertions.
(すべての呼び出しに関するすべての情報をクリアする。 このメソッドを呼び出すと、.mock のすべてのプロパティは初期状態に戻ります。 このメソッドは実装をリセットしません。 異なるアサーション間でモックを整理する際に便利です。)
次のようなテストファイルがあったとき、mockClear
を呼び出さなくてもmockGetUsers
のmockReturnValue
の値は各テストケース間で上書きされるので、問題なくテストは成功します。
import { describe, expect, test, vi } from "vitest";
import { getValidUsers } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn(),
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
mockGetUsers.mockReturnValue([
{ name: "太郎", age: 32 },
{ name: "一郎", age: 23 },
{ name: "五郎", age: 21 },
]);
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
});
});
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 16:21:14
Duration 238ms (transform 34ms, setup 0ms, collect 29ms, tests 3ms, environment 0ms, prepare 59ms)
では次のように、各テストケースでmockGetUsers
が 1 回だけ呼び出されたことを確認したい場合はどうでしょうか?
import { describe, expect, test, vi } from "vitest";
import { getValidUsers } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn(),
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
expect(mockGetUsers).toHaveBeenCalledTimes(1); // 一度だけ呼び出されることを確認したい
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
mockGetUsers.mockReturnValue([
{ name: "太郎", age: 32 },
{ name: "一郎", age: 23 },
{ name: "五郎", age: 21 },
]);
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
expect(mockGetUsers).toHaveBeenCalledTimes(1); // 一度だけ呼び出されることを確認したい
});
});
FAIL getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
AssertionError: expected "spy" to be called 1 times, but got 2 times
❯ getValidUsers.test.ts:38:26
36| ]);
37|
38| expect(mockGetUsers).toHaveBeenCalledTimes(1);
| ^
39| });
40| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 16:30:29
Duration 254ms (transform 34ms, setup 0ms, collect 26ms, tests 5ms, environment 0ms, prepare 55ms)
この場合、テストは失敗します。モックはテストケース毎に独立しているのではなく、テストケース毎にモック情報を共有しています。mockClear
のドキュメントに、「異なるアサーション間でモックを整理する際に便利です。」と書かれていたのはこういうことだったのですね。
それではmockClear
を記載してテストを試みます。
import { describe, expect, test, vi } from "vitest";
import { getValidUsers } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn(),
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
mockGetUsers.mockClear(); // ここを追記
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
mockGetUsers.mockReturnValue([
{ name: "太郎", age: 32 },
{ name: "一郎", age: 23 },
{ name: "五郎", age: 21 },
]);
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
});
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 16:40:18
Duration 246ms (transform 36ms, setup 0ms, collect 34ms, tests 3ms, environment 0ms, prepare 49ms)
無事テストが通りました。
mockReset
Performs the same actions as mockClear and sets the inner implementation to an empty function (returning undefined when invoked). This also resets all "once" implementations. It is useful for completely resetting a mock to its default state.
(mockClear と同じアクションを実行し、内側の実装を空の関数(呼び出されたときに undefined を返す)に設定します。 これはすべての「once」実装もリセットします。 モックをデフォルトの状態に完全にリセットするのに便利です。)
どうやらmockReset
もmockClear
と同じようなことをしているようです。しかし、mockClear
と異なるのは実装もリセットするということのようです。これはどういうことでしょうか?
ここで一度mockClear
に話を戻します。先ほどのテストでは各テストケースでmockReturnValue
を呼び出して期待する挙動を上書きしていたので、テストが成功していましたが、もし、2 つ目のテストケース側でmockReturnValue
を呼び出さなかった場合はどうなるのか確認してみましょう。
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getValidUsers } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn(),
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
// mockClearを先頭に持ってきました
beforeEach(() => {
mockGetUsers.mockClear();
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
// ここをコメントアウト
// mockGetUsers.mockReturnValue([
// { name: "太郎", age: 32 },
// { name: "一郎", age: 23 },
// { name: "五郎", age: 21 },
// ]);
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
});
FAIL getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
AssertionError: expected [] to deeply equal [ { name: '五郎', age: 21 }, …(2) ]
- Expected
+ Received
- Array [
- Object {
- "age": 21,
- "name": "五郎",
- },
- Object {
- "age": 23,
- "name": "一郎",
- },
- Object {
- "age": 32,
- "name": "太郎",
- },
- ]
+ Array []
❯ getValidUsers.test.ts:31:19
29| const users = getValidUsers();
30|
31| expect(users).toEqual([
| ^
32| { name: "五郎", age: 21 },
33| { name: "一郎", age: 23 },
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 16:57:47
Duration 244ms (transform 28ms, setup 0ms, collect 25ms, tests 6ms, environment 0ms, prepare 43ms)
期待する挙動で上書きしていないので当然の結果ではあるのですが、2 つ目のテストケースが落ちてしまいました。それでは、console.log()
を仕込んでmockGetUsers
の戻り値がどうなっているか確認してみます。
export type User = {
name: string;
age: number | undefined;
}[];
export const getValidUsers = () => {
// as User | null 型を返す予定
const users = getUsers();
console.log(users); // ここにログを仕込む
if (!users) {
return [];
}
const validUser = users
.filter((user) => user.age !== undefined)
.sort((a, b) => a.age! - b.age!);
return validUser;
};
すると下記の結果が得られます。
stdout | getValidUsers.test.ts > getValidUsers > usersがundefinedの場合、空配列を返すこと
null
stdout | getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
null
するとどちらのテストケースのログでもnull
の結果が得られました。これは 1 つ目のテストケースでmockReturnValue
の引数に指定した値です。このことから、mockClear
では、呼び出し回数などの情報を各テストケース間でリセットするが、実装はリセットされないことがわかります。(最初からドキュメントには書いてあるんですが、実際に体験してみないと理解しづらかったです。)
長く寄り道しましたが、それではここで、この節の本題のmockReset
に戻ります。
mockClear
をmockReset
に置き換えてログを確認してみましょう。
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getValidUsers } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn(),
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
beforeEach(() => {
mockGetUsers.mockReset(); // ここを変更
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
});
2 つ目のテストケースでmockGetUsers
の値を変更していないのでテストは落ちたままですが、ログに変化がありました。
stdout | getValidUsers.test.ts > getValidUsers > usersがundefinedの場合、空配列を返すこと
null
stdout | getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
undefined
先ほどはnull
になっていた 2 つ目のテストケースのログが、undefined
に変わっています。このundefined
はどこからきたのでしょうか?せっかくなので Vitest の内部実装に潜ってみます。
mockReset
の実装をみてみると、mockClear
を呼び出した後に、implementation = undefined
を実行しているのが確認できます。
undefined
はここからきていたんですね!また、ドキュメントにあった、「mockClear と同じアクションを実行し」という部分も理解ができました。
mockRestore
Performs the same actions as mockReset and restores the inner implementation to the original function.
Note that restoring a mock created with vi.fn() will set the implementation to an empty function that returns undefined. Restoring a mock created with vi.fn(impl) will restore the implementation to impl.
(mockReset と同じアクションを実行し、内側の実装を元の関数に復元します。 vi.fn()で作成したモックを復元すると、実装が undefined を返す空の関数に設定されることに注意してください。 vi.fn(impl)で作成されたモックをリストアすると、実装が impl にリストアされます。)
ドキュメントに記載されている通り、vi.fn()
で作成したモックだと、undefined
を返却する関数になってしまい、それだとmockReset
との違いがわかりにくいので、コメント部分を少し変更します。
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getValidUsers, User } from "./getValidUsers";
const { mockGetUsers } = vi.hoisted(() => {
return {
mockGetUsers: vi.fn((): User | null => [{ name: "太郎", age: 32 }]), // ここの記述を変更
};
});
vi.mock("./getUsers", () => {
return {
getUsers: mockGetUsers,
};
});
beforeEach(() => {
mockGetUsers.mockRestore(); // ここでmockRestore
});
describe("getValidUsers", () => {
test("usersがundefinedの場合、空配列を返すこと", () => {
mockGetUsers.mockReturnValue(null);
const users = getValidUsers();
expect(users).toEqual([]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
test("取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること", () => {
const users = getValidUsers();
expect(users).toEqual([
{ name: "五郎", age: 21 },
{ name: "一郎", age: 23 },
{ name: "太郎", age: 32 },
]);
expect(mockGetUsers).toHaveBeenCalledTimes(1);
});
});
このテストを実行してみると次の結果とログが得られます。
// 実行結果
FAIL getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
AssertionError: expected [ { name: '太郎', age: 32 } ] to deeply equal [ { name: '五郎', age: 21 }, …(2) ]
- Expected
+ Received
Array [
Object {
- "age": 21,
- "name": "五郎",
- },
- Object {
- "age": 23,
- "name": "一郎",
- },
- Object {
"age": 32,
"name": "太郎",
},
]
❯ getValidUsers.test.ts:33:19
31| const users = getValidUsers();
32|
33| expect(users).toEqual([
| ^
34| { name: "五郎", age: 21 },
35| { name: "一郎", age: 23 },
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 20:25:23
Duration 505ms (transform 87ms, setup 0ms, collect 70ms, tests 15ms, environment 0ms, prepare 137ms)
stdout | getValidUsers.test.ts > getValidUsers > usersがundefinedの場合、空配列を返すこと
null
stdout | getValidUsers.test.ts > getValidUsers > 取得したユーザーリストのうち、ageが登録されているユーザーに絞り、ageの昇順で返却すること
[ { name: '太郎', age: 32 } ]
Received とログをみるとわかるとおり、mockRestore
を実行した 2 回目のテストケースのgetUsers
の値が、[ { name: '太郎', age: 32 } ]
になっています。これはファイルの上部で設定した値と同一です。mockReset
を実行した場合は、2 つ目のテストケースのgetUsers
の戻り値がundefined
になっていたのに対して、mockRestore
を実行した場合は元(vi.fn(impl)で作成されたモック)の実装に戻りました。
まとめ
下記の Mock Functions について深掘りしました。
- mockClear
- mockReset
- mockRestore
それぞれ特徴をまとめると下記の通りです。
- mockClear
- テストケース間のモック情報をリセットする。しかし、実装はリセットせずそのまま。
- mockReset
- テストケース間のモック情報をリセットし、実装もリセットする(undefinedになる)。
- mockRestore
- テストケース間のモック情報をリセットし、元の実装に戻す。
こうやって見返してみると、当たり前ですが、公式ドキュメントに書いてある通りですね笑。しかし、執筆開始時点と現在では、明らかにドキュメントの理解度に差があります。簡潔に記載してあって、いまいち理解できていなかったものが、今では具体的に内容を把握しています。エンジニアには、「大事なことはそこに書いてあるから公式ドキュメントを読むべし」という原則のようなものがあると思いますが、書いてあったとしても理解できるとは限らないので、今回のように「わからなかったらわかるまで試すべし」まで実践できると良いなぁと思いました。
Discussion