こんにちは。Legalscape エンジニアの古矢です。
先日、社内のバックエンド API を npm 管理から monorepo の pnpm workspace へ統合しました。ロジック変更はなく、ディレクトリ移動と lockfile の統合、ビルド・CI の追従だけの、いわば引っ越し作業です。ところが CI で全テストを回すと、プロダクトコードに全くの変更がないにもかかわらず、あるテストファイルが連鎖的に落ちました。
先に言ってしまうと、修正自体は 30 分で終わる 1 行でした。本記事のフォーカスはそこではなく、「なぜこのテストは、npm 時代はずっと通っていたのか?」という問いです。この問いを追いかけたら、もっともらしい仮説が次々と倒れ、最初に出した結論も半分間違っていました。その顛末と、「疑いが足りなかったのではなく、疑って検証したのに間違えた」のはなぜか、という話を書きます。
問題: プロダクトコード不変なのに、テストが連鎖的に落ちる
CI で落ちたのは、あるサービスクラスのテストファイルでした(クラス名・関数名は一般的な名前に改変しています)。
import models from '@/models'; import ContentService from '@/services/ContentService'; afterEach(() => { jest.resetAllMocks(); // ← 問題の後始末 }); describe('resolveAccessMap', () => { test('部分ヒット時のケース', async () => { // models.policy.resolvePolicy に spy を張り、1 回分だけモック値を仕込む jest.spyOn(models.policy, 'resolvePolicy').mockResolvedValueOnce(partialResult); // ... }); }); describe('getContentPublic', () => { test('公開期間外のコンテンツは取得できない', async () => { // resolvePolicy が「本物」である前提のテスト。SUT(テスト対象)内部の分割代入で TypeError const response = await ContentService.getContentPublic({ organization, id: '...' }); expect(response.code).toBe(404); }); });
落ちるテストは単体では通り、実行順に依存して落ちる。テスト分離の問題の典型的な匂いです。直接原因はすぐ特定できました。jest.resetAllMocks() は、jest.spyOn で置き換えたメソッドを元の実装に戻しません。呼び出し履歴とモック実装(mockResolvedValueOnce のキューを含む)をクリアするだけで、メソッドは「undefined を返す空のモック」のまま残ります。先行テストの spy が afterEach を生き延び、後続テストの SUT 内部で const { policyMap } = await models.policy.resolvePolicy(...) が undefined を分割代入して死んでいたのです。
修正は 1 行です。
afterEach(() => {
- jest.resetAllMocks();
+ jest.restoreAllMocks();
});
restoreAllMocks() は spy を確実に元へ戻す標準パターンで、プロダクトコードは不変のまま解決します(jest 設定の restoreMocks: true で恒久化もできます)。
しかし、この spy 漏れの機構に、npm も pnpm も登場しません。なのに npm 時代の CI はずっと green でした。この点は疑問が残ります。
仮説を立てるが棄却される
仮説 1:「jest のバージョンが上がったから」——最小再現で棄却
移行で lockfile が再解決され、jest は 29.5 → 29.7 相当に動いていました。「マイナーバージョンで resetAllMocks の挙動が変わったのでは」。もっともらしい仮説です。
そこで spyOn + resetAllMocks + 後続テストという骨格だけの最小再現を書き、jest 29.5 / 29.7 の両方で実行しましたが、差は出ませんでした。どの組み合わせでも同じように spy が漏れて落ちます。「バージョンは無関係」。仮説 1 はここで棄却されました。……と、このときは思っていました。この実験には欠陥があったのですが、それは後の方でわかったのです。
仮説 2:「main の green は幻だったのでは」——記録の突合で棄却
次に疑ったのは過去の CI そのものです。「当該テストは最近追加されたもので、green だった実行には含まれていなかったのではないか」。これは記録の突合で白黒がつきます。テストを追加した PR は green run より前にマージされており、main の CI は当該テストを実行したうえで green でした。仮説 2 もここで棄却。
仮説 3:「pnpm の解決構造が引き金」——実物 A/B で暫定クローズ
残る手は、移行前の origin/main をローカルでそのまま動かすことです。使い捨ての git worktree に main をチェックアウトし、当時の package-lock.json で npm ci、Docker のテスト用 DB を立てて当該テストを実行。同じコードを pnpm 構成の node_modules でも実行します。
| node_modules 構成 | 当該テスト |
|---|---|
| npm(当時の package-lock で構築) | ✅ pass |
| pnpm(移行後の lockfile で構築) | ❌ fail |
コードは同一で、変わったのは node_modules だけ。jest のバージョンは仮説 1 で「無関係」と棄却済みだったため、当時はこれで「pnpm の解決構造そのものが引き金」と結論づけました。
とはいえ、解決構造がテストの成否を変える具体的な機構として思いつくのは、モジュールの二重化くらいですが models は我々のパッケージ内のコードであり、二重実体が起きる典型は 3rd party のバージョン衝突なので、これはあり得ないと棄却。結局、機構的には main でも漏れるはずなのに npm 構成では表面化しない理由は未解明のままでした。ただ、修正(restoreAllMocks)はどちらの解釈でも安全なので、調査はここで一旦クローズしてマージしました。
実務判断としては妥当だったと思いたいのですが、結果的には誤った結論をしばらく信じていたことになります。「未解明」の引っかかりだけが残りました。
発見: reset 後の spy が元実装を呼んでいた
未解明のまま残していた「npm 構成ではなぜ表面化しないのか」を次の方法で再検証しました。
- 使い捨て worktree に npm 構成を再現し、spy が
resetAllMocks後にどんな状態で残っているのかを直接見る - 実テストと同じ形で spy を張り、reset を通したあとにそのメソッドを呼んでみる
jest.spyOn(models.policy, 'resolvePolicy').mockResolvedValueOnce(stubResult); jest.resetAllMocks(); jest.isMockFunction(models.policy.resolvePolicy); // => true。spy はモックのまま残っている。しかし…… models.policy.resolvePolicy(organization, accountId, docs); // => undefined ではない。本物の実装が呼ばれ、Promise が返ってきている
モックのままなのに、呼び出しが元実装に届いており、これは想定と違います。この npm ツリーの jest-mock は 29.5.0。手元の pnpm ツリーの 29.7.0 とソースを比較すると、mockReset(resetAllMocks が全モックへ呼ぶもの)に決定的な差がありました。
// jest-mock 29.5.0 f.mockReset = () => { f.mockClear(); this._mockConfigRegistry.delete(f); if (spyState != null) { spyState.reset?.(); // ← spyOn で作った spy を元実装に戻す } return f; }; // jest-mock 29.7.0 f.mockReset = () => { f.mockClear(); this._mockConfigRegistry.delete(f); return f; // ← 復元しない };
さらに調べていくと、jest 29.3〜29.4 で mock の挙動に破壊的変更が入り(mockReset が spy を復元するようになった。Issue #13916)、PR #14429 によって 29.7.0 で差し戻されていたことがわかりました。
package.json でのバージョン指定は "jest": "^29.0.0" であったため、この挙動差は移行による lockfile 再解決によって浮き彫りになったとも言えます。整理すると以下です。
- npm 時代: stale な package-lock が jest 一式(本体も間接依存の
jest-mockも)を 29.5.0 で丸ごと凍結 →resetAllMocksが spy をたまたま復元してくれていた → green - pnpm 移行: 再解決で jest 一式が揃って 29.7.0 へ → そのうち挙動変更を持っていた
jest-mockのmockResetが spy を復元しなくなった → 潜在バグが顕在化
決定打として、npm 構成において node_modules はそのままにして jest-mock だけを 29.7.0 に差し替える実験をすると、バグ挙動が再現しました。これで真犯人は、lockfile が挙動変更ごと凍結していた jest-mock のマイナーバージョンであったことがわかりました。
冒頭で「半分間違っていた」と書きましたが、これは「pnpm 移行が引き金」という点では正しく、「解決構造が原因」という点で間違っていたという意味でした。
仮説 1 の実験はなぜ間違えたのか
仮説 1 の最小再現は「jest 29.5 でも 29.7 でも漏れる」と観測し、バージョン説を棄却しましたが、jest だけを 29.5 に差し替えて検証してしまったことが間違いでした。jest を 29.5 に入れ替えても差し替わるのは CLI のエントリポイント側で、テストプロセス内の require('jest-mock') は依存ツリー上に解決されている版を読むため、そこがずれていたという初歩的なミスでした。
問題が見つかりにくかった原因は以下であると考えています。
- npm → pnpm 移行と、モジュールのバージョン変更が重なった点
- AI コーディングエージェントに任せた実験は数分で返ってくるため、「実験系そのものを見直す間」がなかった点(信じてしまったのは AI の意見ではなく、自分が実行を指示した実験の観測結果であった。そして実験系の欠陥は、観測結果には現れない)
まとめ
今回落ちたテストは、jest-mock が一時期だけ持っていた挙動に知らずに寄りかかっていた我々のテスト側の潜在バグが、npm → pnpm 移行に伴うモジュールバージョン再解決によって顕在化したというものでした。
この実体験に基づくと、やはり時間を割くべきは実験や設計の妥当性を検証することにあると言えそうです。AI の出力を鵜呑みにせず疑うことは現代の AI 開発の基礎ですが、今回の失敗ケースは疑いはしたものの、それを確かめるための実験自体に不備があったというパターンでした。壊れた実験系が返す「問題なし」は、本物の「問題なし」と見分けがつきにくいため、疑いに使う道具そのものを検証する必要がありました。その必要性に気づけるかどうかは、やはり知識や経験がものをいうところだと感じており、疑えるようになるために知識を得るといった目的意識が大事であると、この一件を整理し直してみて改めて思いました。
さいごに
Legalscapeではエンジニア全方面で募集中です。ドキュメント処理やAIを用いた開発に興味がある方、弊社に興味が湧いた方がいればぜひお気軽にご連絡ください。