品質保証 2026年10月29日

JestとVitestでmockResetの意味が違った。同じテストをJest 30とVitest 2〜5に流したら、片付けたはずのモックに残るものが版ごとに割れた

XECIN テスト自動化テスト設計JavaScript技術検証

社内のあるリポジトリは、API 側のテストが Jest、画面側のテストが Vitest で書かれています。同じ人が両方を行き来して書くので、設定ファイルを開くと clearMocks や restoreMocks といった同じ名前が両側に並んでいます。

先日、画面側のテストを直していて、API 側で見慣れた beforeEach(() => mock.mockReset()) をそのまま写しかけたところで手が止まりました。この1行は、Vitest でも Jest と同じ意味なのか。9 月に Vitest 5(5.0.0 は 2026-09-03 公開)が出ていたこともあって、上げる前に一度ちゃんと確かめておきたくなりました。

モックの後片付けがずれても、テストはたいてい緑のままです。緑のまま、確かめている中身だけが変わる。『動いた=品質OK ではない』というのは、こういうところで効いてくる話なんですよね。テストは目的ではなく手段です。だからこそ、手段のほうが黙って変わっていないかは、別に測っておく必要があります。

同じテストを5つのランナーに流した

社内のローカル環境(Windows 11、Node.js 22.14.0)に、Jest 30.5.2 と Vitest 2.1.9 / 3.2.7 / 4.1.12 / 5.0.5 を別々のフォルダに入れました。テスト本体は1つだけ用意し、先頭の import だけをランナーごとに差し替えます。

判定はテストの合否にしませんでした。合否にすると「どこが違うか」が潰れて、通った・落ちたしか残らないからです。代わりに、観測した値をファイルに書き出し、ランナー同士で突き合わせました。観測する値は16個と、1本のテストの合否です。設定は「既定」「clearMocks」「mockReset(Jest では resetMocks)」「restoreMocks」の4通りで、5ランナー × 4通りの20回を回して25.5秒でした。

観測用テストの芯はこれです。

// 観測用テスト(抜粋)。先頭だけランナーごとに差し替える
//   Jest:   const M = jest;
//   Vitest: vitest から vi などを import して const M = vi;
const seen = {};
afterAll(() => { fs.writeFileSync(process.env.OUT_FILE, JSON.stringify(seen)); });

describe('2本目のテストから見える値', () => {
  const fallback = M.fn(() => 'original');
  const target = { send: () => 'real' };

  it('1本目: 記録と stub と spy を残す', () => {
    fallback();
    fallback.mockReturnValue('stub');
    M.spyOn(target, 'send').mockReturnValue('spied');
  });

  it('2本目: 何が片付いているかを書き出すだけ', () => {
    seen.calls = fallback.mock.calls.length; // 先に数える(呼ぶと増える)
    seen.fallback = String(fallback());
    seen.send = String(target.send());
    seen.sendIsMock = M.isMockFunction(target.send);
  });
});

// 実行結果(既定の設定):
//   Jest 30.5.2 / Vitest 2.1.9・3.2.7・4.1.12 → calls 1, fallback 'stub', send 'spied'
//   Vitest 5.0.5                              → calls 0, fallback 'stub', send 'spied'

この観測は「1本目のあとに2本目が走る」順番に頼っています。テストの順番を入れ替える設定(sequence.shuffle)を有効にすると壊れるので、観測専用と割り切りました。本当は場面ごとにファイルを分けて、順番に頼らない形にしたほうがいいです。ここは改善の余地ありです。

片付けの操作ごとに並べると、Vitest の中だけで割れ目が3か所あった

まず、テストの中で直接呼ぶ操作の結果です。表の「fn」は、作るときに元の実装(‘original’ を返す関数)を渡したモック、「spy」は本物のメソッドに spyOn したモックです。

操作と見たものJest 30.5.2Vitest 2.1.9Vitest 3.2.7Vitest 4.1.12Vitest 5.0.5
fn に stub を仕込んで mockReset → 戻り値undefinedundefined元の実装元の実装元の実装
spy に stub を仕込んで mockReset → 戻り値undefinedundefined本物本物本物
fn に mockRestore → 戻り値undefined元の実装元の実装元の実装元の実装
restoreAllMocks のあと、fn の呼び出し記録残る(1)消える(0)消える(0)残る(1)残る(1)
restoreAllMocks のあと、fn の stub残る元の実装に戻る元の実装に戻る残る残る
beforeAll で呼んだ記録を、最初のテストで読む(既定の設定)11110

太字が、左隣の版から変わったところです(Jest の列は Vitest 全体との違い)。Vitest の中だけで見ても、割れ目は3か所ありました。

  • 3.2.7 から、mockReset が「undefined を返す関数」ではなく「作ったときの実装」に戻すようになった。spy なら本物のメソッドに戻る
  • 4.1.12 から、restoreAllMocks が spy だけを本物に戻し、fn の記録と stub には触らなくなった
  • 5.0.5 では、beforeAll で呼んだ記録が最初のテストから見えない(clearMocks が既定で効いている)

Jest 30.5.2 との違いはさらに別の場所にあって、mockReset と mockRestore は Vitest 3 以降と結果が違い(Jest はどちらも undefined)、restoreAllMocks は Vitest 4 以降と同じでした。

設定で片付けると、2本目のテストから何が見えるか

次は、先ほどの観測用テストを設定4通りで流した結果です。セルは「呼び出し記録 / fn の戻り値 / spy の戻り値」の順で、太字は左隣の版から変わったところです。

設定Jest 30.5.2Vitest 2.1.9Vitest 3.2.7Vitest 4.1.12Vitest 5.0.5
既定1 / stub / spied1 / stub / spied1 / stub / spied1 / stub / spied0 / stub / spied
clearMocks0 / stub / spied0 / stub / spied0 / stub / spied0 / stub / spied0 / stub / spied
mockReset(Jest は resetMocks)0 / undefined / undefined0 / undefined / undefined0 / original / real0 / original / real0 / original / real
restoreMocks1 / stub / real0 / original / real0 / original / real1 / stub / real0 / stub / real

mockReset の行の spy はモックのまま本物を呼んでいる状態で、restoreMocks の行は本物のメソッドそのものに戻っています(isMockFunction が false)。

同じ「mockReset を有効にする」でも、Jest と Vitest 2 では fn も spy も undefined を返すようになり、Vitest 3 以降では作ったときの姿に戻ります。Jest から写したテストが「リセットしたら undefined」に頼っていると、Vitest 3 以降では別の値で動きます。

設定の名前そのものも違います。Jest の resetMocks を、名前のまま Vitest の設定に写すとどうなるかも試しました。Vitest 5.0.5 は警告を1行も出さず、リセットもしませんでした。逆に Jest 30.5.2 に mockReset と書くと「Unknown option “mockReset”」の Validation Warning が出ます。黙るのは Vitest 側でした。

設定を明示すれば揃う、と思っていた

正直なところ、最初は「Jest の列と Vitest 5 の列を見比べて、違うところだけ直せばいい」と考えていました。

そこで、設定を3つとも明示したうえで、片付けの前提を確かめる見張りのテスト(後で載せます)を書きました。設定は clearMocks を true、mockReset を false、restoreMocks を true です。これでどの版でも同じ結果になるはずでした。

結果は、Vitest 4.1.12 と 5.0.5 は2本とも通り、3.2.7 と 2.1.9 は2本目で落ちました。

AssertionError: expected 'original' to be 'stub' // Object.is equality
      Tests  1 failed | 1 passed (2)

同じ restoreMocks: true でも、3.2.7 以前は fn の stub まで作ったときの実装に戻すからです。設定を書けば揃う、は半分しか当たっていませんでした。

restoreMocks の行をもう一度見ると、Vitest 4.1.12 と Jest 30.5.2 は同じで、Vitest 2.1.9 と 3.2.7 だけが違います。Jest から移す人と、Vitest 3 から 4 以降へ上げる人とでは、壊れる場所が逆です。今思えば、1列ずつ比べて済む話ではありませんでした。

beforeEach の1行は、Vitest でだけ各テストの後にモックを呼ぶ

写しかけた1行の話に戻ります。波かっこの無い式本体で mockClear() を返すと、こうなりました。

// Jest から写してきた1行(波かっこが無い)
let sent = 0;
const notify = vi.fn(() => { sent += 1; });
beforeEach(() => notify.mockClear());

it('a', () => {});
it('b', () => {});
it('c', () => {});

// 実行結果:
//   どのテストの中でも、afterEach の中でも notify.mock.calls.length は 0
//   3本終わった時点で sent は 3(Vitest 2.1.9〜5.0.5)。Jest 30.5.2 では 0

// 直し方: 何も返さない形にする
beforeEach(() => { notify.mockClear(); });

Vitest は、beforeEach が関数を返すと、それをそのテストの後片付けとして呼びます。mockClear() も mockReset() もモック自身を返すので、波かっこが無いとモックそのものが後片付けとして登録されます。Jest 30.5.2 では同じ1行でも何も起きず、sent は0のままでした。async を付けても逃げられず、Vitest 5.0.5 では2本のテストの後に2回呼ばれました。

厄介なのは、記録では見えないことです。

notify.mock.calls.length をテストの中と afterEach で数えると、3本とも 0 でした。後片付けは afterEach よりも後に走り、そこで増えた記録は次のテストの beforeEach がすぐ消します。実装の中に数える変数(sent)を置いて、やっと3回が見えました。モックの実装が DB への書き込みや送信の記録を持っていれば、テストの外で余計な1回が毎回起きていることになります。

モックが失敗する実装を持っていると、もっと分かりにくくなります。テストの中で mockImplementation に Promise.reject を仕込み、検証は1行も落ちない形にしたテストが、Vitest 2.1.9〜5.0.5 では仕込んだエラーで落ちました。4.1.12 と 5.0.5 のスタックトレースが指していたのは mockImplementation を書いた行で、beforeEach の行はどこにも出てきません。Jest 30.5.2 では通ります。

この書き方が残っていないかは、grep で洗えます。

# 式本体の beforeEach / beforeAll を探す(波かっこで始まるものは拾わない)
grep -rnE "(beforeEach|beforeAll)\(\s*(async\s*)?(\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*[^{[:space:]]" \
  --include="*.test.*" --include="*.spec.*" .

# 実行結果(6通りの書き方を並べた見本に当てたとき):
# ./hooks-sample.test.js:1:beforeEach(() => notify.mockClear())
# ./hooks-sample.test.js:3:beforeEach(async () => callApi.mockReset())
# ./hooks-sample.test.js:4:beforeAll((ctx) => server.listen())

拾えたのは6通り中3通りです。波かっこ付きと afterEach は拾わず、狙いどおりでした。ただ、server.listen() のように関数を返さないものも拾いますし、=> の後で改行してからモックを書く形は拾えません。最後は目で見る前提の道具です。

版を上げる前・移す前の受け入れ基準

ここまでを、移行や版上げの受け入れ基準(Acceptance Criteria)として書き直しました。

  1. 設定ファイルに clearMocks / mockReset / restoreMocks の3つが明示されている。Jest の resetMocks という名前のまま写していない
  2. 下の見張りのテストが、上げる前の版と上げた後の版の両方で緑になる。落ちる版があれば、どの前提が変わったのかを説明できる
  3. 式本体の beforeEach / beforeAll が grep で0件。拾われたものは1件ずつ目で見て、関数を返さないことを確かめてある
  4. mockReset / resetAllMocks を呼んでいる箇所を数え、戻り値が undefined になることに頼っているテストが無いことを確かめてある

基準2の例を具体的に書くと、「1本目のテストで fn に stub を、メソッドに spy を仕込む。2本目のテストが始まった時点で、呼び出し記録は0、spy は本物に戻っている、fn の stub は残っている」です。見張りのテストはこれをそのままコードにしたものです。

// vitest.config.mjs — 既定値に頼らず3つとも書く
// (Jest の resetMocks を写しても、Vitest は警告なしで無視する)
export default {
  test: {
    clearMocks: true,   // 記録は各テストの前に消す(Vitest 5 の既定と同じ。4 以前の既定は false)
    mockReset: false,   // Jest と意味が違うので使わない。戻したいテストの中で明示的に戻す
    restoreMocks: true, // spy だけを本物に戻す(Vitest 4 以降と Jest 30 の意味)
  },
};

// mock-cleanup.canary.test.mjs — 版を上げたら最初に見る見張り
import { vi, describe, it, expect, beforeAll } from 'vitest';

describe('モックの後片付けの前提', () => {
  const recorder = vi.fn();
  const fallback = vi.fn(() => 'original');
  const target = { send: () => 'real' };

  beforeAll(() => { recorder('beforeAll'); });

  it('1本目: わざと汚す', () => {
    fallback.mockReturnValue('stub');
    vi.spyOn(target, 'send').mockReturnValue('spied');
  });

  it('2本目: 前提どおり片付いている', () => {
    expect(recorder).toHaveBeenCalledTimes(0);         // clearMocks が効いている
    expect(fallback()).toBe('stub');                   // vi.fn の stub は残る
    expect(vi.isMockFunction(target.send)).toBe(false); // spy は本物に戻っている
  });
});

// 実行結果(上の設定で):
//   Vitest 4.1.12 / 5.0.5 → 2 passed
//   Vitest 2.1.9 / 3.2.7  → 2本目で expected 'original' to be 'stub'
// 設定を書かずに流すと、4.1.12 は1つ目の expect で、5.0.5 は3つ目の expect で落ちる

このテストは、アプリの挙動を何も確かめていません。確かめているのはテストの土台のほうです。テストが緑であることの意味を、版を上げるたびに同じ物差しで確かめるための1本です。

ここは好みが分かれるところですが、私は clearMocks を true にする側です。Vitest 5 の既定と同じにしておけば、4 から上げたときに記録の見え方が変わりません。その代わり、beforeAll で呼んだ記録をテストで確かめているテストは0に見えるようになるので、設定を入れる前に grep で探しておきます。

振り返って

並べてみて分かったのは、「Jest と Vitest の違い」という1本の線では足りないことでした。Vitest の中にも、3.2.7・4.1.12・5.0.5 の3か所に割れ目があり、どこから来てどこへ行くかで、直す場所が変わります。

画面側を Vitest 5 に上げる前に、まずこの見張りのテストを今の版で緑にしてから上げるつもりです。API 側の Jest にも同じ前提のテストを置いて、両側で「片付け」の意味がどう違うのかを、文章ではなくテストで残しておきたいと思っています。式本体の beforeEach は、grep ではなく lint の規則で止められないかも調べてみます。