45395 - シコウサクゴ -

sys.modules.setdefault()でMock一貫性を担保する:pytest相互干渉の根治

2026-04-16
AI駆動開発
AI駆動開発
Python
pytest
Mock
テスト戦略
Last updated:2026-04-21
8 Minutes
1558 Words

pytestで数百件のテストを回していると、単体では通るのに並べると落ちるという現象に遭遇しました。原因を追うと、複数のテストファイルでsys.modules[...] = MagicMock()を直接上書きしており、実行順序によってMock内容が変わっていたのです。

解決策は単純で、sys.modules.setdefault()に置き換えるだけ。この小さな変更でテスト独立性が回復した記録です。

問題:テスト順序で結果が変わる

症状

Terminal window
1
# 単独実行: 通る
2
pytest tests/test_module_a.py # ✅
3
pytest tests/test_module_b.py # ✅
4
5
# 全部まとめて実行: 落ちる
6
pytest tests/ # ❌ test_module_b.py で AttributeError

test_module_b.pyが単独だと通るのに、test_module_a.pyの後に走ると落ちる。典型的なテスト間の状態汚染です。

原因調査

tests/test_module_a.pyの先頭にこう書いてありました。

test_module_a.py
1
import sys
2
from unittest.mock import MagicMock
3
4
sys.modules["heavy_external_lib"] = MagicMock()
5
sys.modules["heavy_external_lib"].process = MagicMock(return_value=42)
6
7
from myapp import module_a

tests/test_module_b.pyの先頭はこうです。

test_module_b.py
1
import sys
2
from unittest.mock import MagicMock
3
4
sys.modules["heavy_external_lib"] = MagicMock()
5
sys.modules["heavy_external_lib"].process = MagicMock(return_value="hello")
6
7
from 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は、ファイルを読み直さずにキャッシュから返します。

1
import some_lib # 1回目: ファイル読み込み + sys.modulesに登録
2
import some_lib # 2回目: sys.modules["some_lib"] を返すだけ

テストがMockをsys.modules[...] = MagicMock()で直接上書きすると、そのテストファイルがインポートされたタイミングでしか効きません。別のテストが先にモジュール本体をインポートしていたら、そちらが使われます。

典型的な事故パターン

1
1. conftest.py で myapp.module_a を import(heavy_external_lib は本物が読まれる)
2
2. test_module_a.py が sys.modules["heavy_external_lib"] = MagicMock() で上書き
3
3. test_module_a.py のテストは Mock を使う(のように見える)
4
しかし module_a 内部の `from heavy_external_lib import process` は
5
既に本物の process を参照しているので、Mockが効かない or 効く が不安定

解決策:sys.modules.setdefault()

変更はたった一行

1
# Before
2
sys.modules["heavy_external_lib"] = MagicMock()
3
4
# After
5
sys.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に寄せます。

tests/conftest.py
1
import sys
2
from unittest.mock import MagicMock
3
4
# プロジェクト全体で共通のMockを一箇所で登録
5
for lib_name in ["heavy_external_lib", "proprietary_sdk", "gpu_driver"]:
6
sys.modules.setdefault(lib_name, MagicMock())

各テストファイルからMock登録コードを削除し、挙動を揃えるのはフィクスチャ側で行います。

test_module_a.py
1
def test_process_returns_42(monkeypatch):
2
monkeypatch.setattr(
3
"heavy_external_lib.process",
4
lambda x: 42
5
)
6
# テスト本体
test_module_b.py
1
def test_process_returns_hello(monkeypatch):
2
monkeypatch.setattr(
3
"heavy_external_lib.process",
4
lambda x: "hello"
5
)

monkeypatchはpytestのフィクスチャで、テスト終了時に自動で元に戻しますsys.modules直接書き換えと違い、状態汚染が起きません。

setdefaultが効かないケースと対処

ケース1: 本物のモジュールが先にインポートされている

conftest.py
1
import myapp.module_a # ← これが heavy_external_lib の本物を読み込む
2
3
# tests/setup.py
4
sys.modules.setdefault("heavy_external_lib", MagicMock()) # 手遅れ

この場合、setdefaultしても既に本物が入っているので意味がありません。

対処:Mock登録は本物のimportより前に行いますconftest.pyの一番上、プロジェクトモジュールを触る前に置きます。

conftest.py
1
import sys
2
from unittest.mock import MagicMock
3
4
# 最初にMockを登録
5
sys.modules.setdefault("heavy_external_lib", MagicMock())
6
7
# その後でプロジェクトモジュールをimport
8
import myapp

ケース2: サブモジュールを持つライブラリ

1
sys.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しても足りません。

対処:サブモジュールも登録します。

1
MOCKS = ["heavy_external_lib", "heavy_external_lib.submodule", "heavy_external_lib.client"]
2
for name in MOCKS:
3
sys.modules.setdefault(name, MagicMock())

ケース3: テスト中にMockの挙動を変えたい

sys.modules直登録だと、テストごとに挙動を変えるのが難しい。monkeypatchに寄せます。

1
def test_case_a(monkeypatch):
2
monkeypatch.setattr("heavy_external_lib.process", lambda: "A")
3
assert myapp.run() == "A"
4
5
def test_case_b(monkeypatch):
6
monkeypatch.setattr("heavy_external_lib.process", lambda: "B")
7
assert myapp.run() == "B"

各テスト終了時に元に戻るので、相互干渉しません。

検出:テスト順序ランダム化で再現する

テスト順序依存のバグは、普段は出ません。順序をランダム化して回すと顕在化します。

Terminal window
1
pip install pytest-randomly
2
pytest --randomly-seed=last # 前回と同じシードで再現

CI上でpytest-randomlyを有効にしておくと、順序依存バグが混入したタイミングで検知できます。

実践で学んだこと

1. sys.modules直接書き換えは最終手段

可能な限りmonkeypatchpytest-mockmockerフィクスチャを使います。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箇所のルールで運用します。複数箇所で触ると、必ずどこかで衝突します。

Article title:sys.modules.setdefault()でMock一貫性を担保する:pytest相互干渉の根治
Article author:45395
Release time:2026-04-16

記事へのご質問・ご感想をお聞かせください

フィードバックを送る