0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vitestの beforeEach(() => mock.mockReset()) がテストを落とす理由と直し方

0
Posted at

環境・前提条件

  • OS: Windows 11 Pro
  • Node.js: 24.14.1
  • Vitest: 4.1.10(environment: 'node')
  • TypeScript: 5.9.3
  • 対象: 自社で運用している Cloudflare Workers のバックエンド。外部の AI API を呼ぶ関数を vi.fn() で差し替えてテストしている

この記事の要点

  • beforeEach に渡した関数が関数を返すと、Vitest はそれを後片付けとして各テストの後に呼びます。
  • mockReset() と mockClear() はモック自身を返すため、波かっこの無いアロー関数で書くと後片付けとして登録されます。
  • テスト中に「失敗する実装」を仕込むと、後片付けの呼び出しでその失敗が発火し、無関係に見えるエラーで落ちます。

なぜ beforeEach(() => mock.mockReset()) で落ちるのか?

式本体のアロー関数は mockReset() の戻り値を返し、それがモック自身という関数なので、Vitest が後片付けとして各テストの後に呼ぶからです。

Vitest の beforeEach は、戻り値を後片付け関数として扱う仕様です。公式ドキュメントには「beforeEach can also return an optional cleanup function.」とあり、afterEach と同じ扱いで、ほかの afterEach フックより後に実行されると書かれています(Vitest: beforeEach)。

一方、mockReset() と mockClear() の型は次のとおりで、戻り値はモック自身です(Vitest: Mocks)。

function mockReset(): Mock<T>
function mockClear(): Mock<T>

モックは呼び出せる関数なので、次の2行は意味が違います。

beforeEach(() => callLLM.mockReset())      // callLLM を返す → 後片付けとして登録される
beforeEach(() => { callLLM.mockReset() })  // 何も返さない → 前準備だけ

実行側(@vitest/runner 4.1.10)でも、フックの戻り値の型だけを見て判定しています。

function getBeforeHookCleanupCallback(hook, result, context) {
  if (typeof result === "function") {
    // 後片付けとして、タイムアウト付きで登録する
  }
}

戻り値が「意図して返した後片付け関数」なのか「たまたま関数だった値」なのかは区別されません。

実際にはどんな落ち方をしたのか?

検証はすべて通ったのに、テスト中に仕込んだモックのエラーが失敗として報告されました。原因の行を指さないので、読み違えやすい落ち方です。

問題が出たのは、AI API の呼び出しが混雑(503)や上限(429)で失敗したら、見回り処理がそこで止まることを確かめるテストです。テストの中で、モックが毎回失敗するように実装を差し替えます。

// 要点だけに簡略化(関数名・引数は実物と異なる)
const callLLM = vi.fn()
beforeEach(() => callLLM.mockReset())   // ← 問題の書き方

it('混雑(503)が出たら1回で止める', async () => {
  callLLM.mockImplementation(() => Promise.reject(new Error('Gemini API 503 ...: high demand')))
  const r = await runSweep(env)
  expect(callLLM).toHaveBeenCalledTimes(1)   // ここは通る
})

流れを追うとこうなります。

  1. beforeEach が callLLM を返し、後片付けとして登録される
  2. テスト本体が失敗する実装を仕込み、検証はすべて通る
  3. テストの後、後片付けとして callLLM() が引数なしで呼ばれる
  4. 仕込んだ実装が拒否された Promise を返し、Vitest がそれを待って失敗扱いにする

出力には Error: Gemini API 503(…): high demand と、mockImplementation を書いた行が表示されます。これだけ見ると「わざと投げさせたエラーを、処理のどこかが握りつぶせていない」と読めてしまい、テスト対象のコードを疑いたくなります。実際の原因は1行上の beforeEach です。私たちのテストファイルでは、この書き方にすると5件中2件が落ちます(失敗を仕込むテスト2件)。

落ちない場合は安全なのか?

安全ではありません。モックの実装が成功を返す場合は落ちないだけで、テストの後にモックが余計に1回呼ばれています。

最小の再現で確かめました。成功を返すモックを mockClear() の式本体で初期化し、テスト本体では1回も呼ばずに、呼ばれた回数を別の変数で数えます。

let calls = 0
const notify = vi.fn(async () => { calls++ })
beforeEach(() => notify.mockClear())

it('1本目', () => { expect(notify).toHaveBeenCalledTimes(0) })
it('2本目', () => { expect(notify).toHaveBeenCalledTimes(0) })
it('3本目', () => { expect(calls).toBe(0) })  // 実際は 2 で落ちる

1本目と2本目はどちらも通ります。次の beforeEach が呼び出し記録を消すので、テストからは余計な呼び出しが見えません。それでも3本目の時点で calls は2です。テストが終わるたびに1回ずつ呼ばれています。

後片付けは afterEach より後に走るので、afterEach の中で呼び出し回数を確かめても見えません。モックの実装が DB への書き込みや外部送信の記録を持っていると、テストの後始末と食い違う状態が残ります。

実際、私たちのリポジトリにもこの書き方が1か所ありました(beforeEach(() => notifyAlert.mockClear()))。モックが成功を返すためテストは通っていましたが、各テストの後に余計な呼び出しが起きていました。この記事を書く過程で見つかり、波かっこで囲んで直しました。

どう直すのがよいか?

本体を波かっこで囲み、何も返さないようにします。1文字の差なので、理由をコメントに残しておくと戻されにくくなります。

// 波かっこで囲む(mockReset() は関数を返すので、そのまま返すと後片付けとして呼ばれてしまう)
beforeEach(() => { callLLM.mockReset() })

ほかの手としては、設定の mockReset: true で、各テストの前に全モックをリセットする方法があります(Vitest: mockReset(設定))。この場合 beforeEach 自体が要らなくなります。ただし全テストファイルに効くため、既存のテストが前のテストで仕込んだ実装に頼っていないかを確かめてから入れる必要があります。私たちは採っていません。

残っている箇所は、式本体の beforeEach / beforeAll を探す grep で見つかります。

grep -rnE "(beforeEach|beforeAll)\((async )?\(\) => [^{ ]" test

async () => { ... } のように波かっこで始まるものは拾いません。式本体を返すものだけが出ます。

実測値

条件 結果
実際のテストファイル(波かっこあり) 5件すべて成功
同じファイルを beforeEach(() => callLLM.mockReset()) に変更 5件中2件失敗(失敗を仕込む2件。エラーは仕込んだ 503 と「読み取れませんでした」)
最小再現:失敗を仕込む+式本体の mockReset() 失敗
最小再現:同じ内容を波かっこで囲む 成功
最小再現:成功するモック+式本体の mockClear()、テスト本体の呼び出し0回 2本のテストの後でモックの実装が2回実行された
リポジトリ内の式本体の beforeEach / beforeAll テストファイル159本中1本(記事の執筆中に直して0本)

再現手順は次のとおりです。

  1. vi.fn() のモックを作り、beforeEach(() => mock.mockReset()) と書く
  2. テストの中で mock.mockImplementation(() => Promise.reject(new Error('x'))) を仕込み、検証は expect(true).toBe(true) だけにする
  3. npx vitest run を実行すると、そのテストが Error: x で失敗する
  4. beforeEach(() => { mock.mockReset() }) に直すと通る

まとめ

  • beforeEach / beforeAll の戻り値が関数なら、Vitest は後片付けとして呼ぶ
  • mockReset() / mockClear() はモック自身を返すので、式本体のアロー関数で書くと後片付けに化ける
  • 失敗を仕込むテストでは落ち、成功するモックでは黙って余計な呼び出しが起きる。どちらも波かっこで防げる

参考:公式ドキュメント


執筆・運用:株式会社HY(HY Consulting)/横浜駅前の相続・空き家の不動産相談窓口(初回相談無料)
本記事は、自社で運用しているシステムの開発記録です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?