pytestで数百件のテストを回していると、単体では通るのに並べると落ちるという現象に遭遇しました。原因を追うと、複数のテストファイルでsys.modules[...] = MagicMock()を直接上書きしており、実行順序によってMock内容が変わっていたのです。
解決策は単純で、sys.modules.setdefault()に置き換えるだけ。この小さな変更でテスト独立性が回復した記録です。
問題:テスト順序で結果が変わる
症状
1# 単独実行: 通る2pytest tests/test_module_a.py # ✅3pytest tests/test_module_b.py # ✅4
5# 全部まとめて実行: 落ちる6pytest tests/ # ❌ test_module_b.py で AttributeErrortest_module_b.pyが単独だと通るのに、test_module_a.pyの後に走ると落ちる。典型的なテスト間の状態汚染です。
原因調査
tests/test_module_a.pyの先頭にこう書いてありました。
1import sys2from unittest.mock import MagicMock3
4sys.modules["heavy_external_lib"] = MagicMock()5sys.modules["heavy_external_lib"].process = MagicMock(return_value=42)6
7from myapp import module_atests/test_module_b.pyの先頭はこうです。
1import sys2from unittest.mock import MagicMock3
4sys.modules["heavy_external_lib"] = MagicMock()5sys.modules["heavy_external_lib"].process = MagicMock(return_value="hello")6
7from myapp import module_b両方ともheavy_external_libをMockしていますが、戻り値が違う。実行順序によってどちらのMockが生き残るかが変わります。
module_aをインポート済みの状態でtest_module_b.pyがMockを上書きすると、module_aは既にインポート時のMockを参照しているため、新しいMockが効きません。
なぜ起きるか:Pythonのモジュールキャッシュ
Pythonは一度インポートしたモジュールをsys.modulesにキャッシュします。2回目以降のimportは、ファイルを読み直さずにキャッシュから返します。
1import some_lib # 1回目: ファイル読み込み + sys.modulesに登録2import some_lib # 2回目: sys.modules["some_lib"] を返すだけテストがMockをsys.modules[...] = MagicMock()で直接上書きすると、そのテストファイルがインポートされたタイミングでしか効きません。別のテストが先にモジュール本体をインポートしていたら、そちらが使われます。
典型的な事故パターン
11. conftest.py で myapp.module_a を import(heavy_external_lib は本物が読まれる)22. test_module_a.py が sys.modules["heavy_external_lib"] = MagicMock() で上書き33. test_module_a.py のテストは Mock を使う(のように見える)4 しかし module_a 内部の `from heavy_external_lib import process` は5 既に本物の process を参照しているので、Mockが効かない or 効く が不安定解決策:sys.modules.setdefault()
変更はたった一行
1# Before2sys.modules["heavy_external_lib"] = MagicMock()3
4# After5sys.modules.setdefault("heavy_external_lib", MagicMock())setdefault()は「キーが存在しなければセット、存在すれば既存値を返す」。これにより:
- テストファイルA がMockを登録 →
sys.modules["heavy_external_lib"]はMock - テストファイルB が同じMockを登録しようとしても、既存のMockが保持される
- どちらのテストも同じMockインスタンスを参照する
結果、テスト順序に関わらずMockが安定します。
conftest.pyで一元管理する
さらに踏み込むなら、Mockの登録自体をconftest.pyに寄せます。
1import sys2from unittest.mock import MagicMock3
4# プロジェクト全体で共通のMockを一箇所で登録5for lib_name in ["heavy_external_lib", "proprietary_sdk", "gpu_driver"]:6 sys.modules.setdefault(lib_name, MagicMock())各テストファイルからMock登録コードを削除し、挙動を揃えるのはフィクスチャ側で行います。
1def test_process_returns_42(monkeypatch):2 monkeypatch.setattr(3 "heavy_external_lib.process",4 lambda x: 425 )6 # テスト本体1def test_process_returns_hello(monkeypatch):2 monkeypatch.setattr(3 "heavy_external_lib.process",4 lambda x: "hello"5 )monkeypatchはpytestのフィクスチャで、テスト終了時に自動で元に戻します。sys.modules直接書き換えと違い、状態汚染が起きません。
setdefaultが効かないケースと対処
ケース1: 本物のモジュールが先にインポートされている
1import myapp.module_a # ← これが heavy_external_lib の本物を読み込む2
3# tests/setup.py4sys.modules.setdefault("heavy_external_lib", MagicMock()) # 手遅れこの場合、setdefaultしても既に本物が入っているので意味がありません。
対処:Mock登録は本物のimportより前に行います。conftest.pyの一番上、プロジェクトモジュールを触る前に置きます。
1import sys2from unittest.mock import MagicMock3
4# 最初にMockを登録5sys.modules.setdefault("heavy_external_lib", MagicMock())6
7# その後でプロジェクトモジュールをimport8import myappケース2: サブモジュールを持つライブラリ
1sys.modules.setdefault("heavy_external_lib", MagicMock())2# しかし myapp が from heavy_external_lib.submodule import ... と書いていると…from heavy_external_lib.submodule import Xは、sys.modules["heavy_external_lib.submodule"]を探しに行きます。親だけMockしても足りません。
対処:サブモジュールも登録します。
1MOCKS = ["heavy_external_lib", "heavy_external_lib.submodule", "heavy_external_lib.client"]2for name in MOCKS:3 sys.modules.setdefault(name, MagicMock())ケース3: テスト中にMockの挙動を変えたい
sys.modules直登録だと、テストごとに挙動を変えるのが難しい。monkeypatchに寄せます。
1def test_case_a(monkeypatch):2 monkeypatch.setattr("heavy_external_lib.process", lambda: "A")3 assert myapp.run() == "A"4
5def test_case_b(monkeypatch):6 monkeypatch.setattr("heavy_external_lib.process", lambda: "B")7 assert myapp.run() == "B"各テスト終了時に元に戻るので、相互干渉しません。
検出:テスト順序ランダム化で再現する
テスト順序依存のバグは、普段は出ません。順序をランダム化して回すと顕在化します。
1pip install pytest-randomly2pytest --randomly-seed=last # 前回と同じシードで再現CI上でpytest-randomlyを有効にしておくと、順序依存バグが混入したタイミングで検知できます。
実践で学んだこと
1. sys.modules直接書き換えは最終手段
可能な限りmonkeypatchやpytest-mockのmockerフィクスチャを使います。sys.modules触るのは、そのモジュールをそもそもインポートさせたくない(C拡張でインポート自体が重い/GPUドライバなど)場合に限ります。
2. Mock登録はconftest.pyの先頭で
登録タイミングがすべて。他のimportより前に置きます。
3. setdefaultはライブラリ単位で効果が出る
「Mockがあれば使う、なければ作る」という思想で設計すると、テストファイルを追加しても既存テストが壊れません。
4. 順序ランダム化を常設する
pytest-randomlyは入れておきます。順序依存バグは、ある日突然本番CIで大量失敗として現れます。
まとめ
| 問題 | 原因 | 対処 |
|---|---|---|
| テスト単独では通るが並べると落ちる | sys.modulesの上書き競合 | setdefaultに変える |
| Mockが効かないことがある | 本物のimportが先に走っている | conftest.pyの先頭でMock登録 |
| テストごとに挙動を変えたい | sys.modules直接管理は不向き | monkeypatch/mockerを使う |
| 順序依存バグが検出できない | 固定順で実行している | pytest-randomlyを入れる |
sys.modulesを触るときは、1プロジェクト1箇所のルールで運用します。複数箇所で触ると、必ずどこかで衝突します。