pytest の conftest.py で sys.path.insert(0, PROJECT_ROOT) を書くのは、テストを手元で動かすための定番テクニックです。しかしこれが、CI で再現できない静かなバグの温床になります。
具体的には、mymodule.foo と foo(プロジェクトルートからの相対)の 両方の import パスが成立 してしまう。同じモジュールが二重ロードされ、Mock が片方にしか効かないという現象に出会いました。本記事はその構造と回避策です。
状況:CI で通ったテストが手元で謎の挙動
ある日、ある単体テストが CI でパスしているのに、手元で実行すると以下のような現象に出会いました。
1from unittest.mock import patch2
3@patch('mymodule.foo.api_client')4def test_foo(mock_client):5 from mymodule import foo6 foo.do_something()7 mock_client.fetch.assert_called_once() # ← ここが失敗エラー: Expected 'fetch' to have been called once. Called 0 times.
しかしモジュールを直接実行すると api_client.fetch は確実に呼ばれている。なぜ Mock が効かないのか?
原因:同じモジュールが二重ロードされていた
調査の末、mymodule.foo と foo という2つの別オブジェクトとして import されていました。
1>>> import sys2>>> 'mymodule.foo' in sys.modules3True4>>> 'foo' in sys.modules # こちらも True!5True6>>> sys.modules['mymodule.foo'] is sys.modules['foo']7False # ← 別オブジェクトつまり、
@patch('mymodule.foo.api_client')はmymodule.fooのapi_clientを Mock 化- 実行時のコードは
fooモジュール(別オブジェクト)のapi_clientを呼んでいる - Mock が効かない
元凶:conftest.py の sys.path.insert
ナイーブに書かれた conftest.py は以下のような形をしています。
1import sys2from pathlib import Path3
4PROJECT_ROOT = Path(__file__).parent5sys.path.insert(0, str(PROJECT_ROOT))これにより、PROJECT_ROOT 配下のファイルが2通りの方法で import 可能になります。
1# 方法1: パッケージとしての絶対 import2from mymodule.foo import bar3
4# 方法2: プロジェクトルートからの相対 import5from foo import bar # これも成立してしまう異なるテストファイルが片方ずつを使うと、sys.modules に両方が登録されます。
なぜ CI では再現しなかったか
CI 環境では PYTHONPATH=$PROJECT_ROOT が事前に設定されており、conftest.py の sys.path.insert が重複処理として吸収されていました。
- CI:
sys.path = [PROJECT_ROOT, ...](既に存在)→conftest.pyの insert は no-op - 手元:
sys.path = [...](PROJECT_ROOT なし)→conftest.pyの insert が効く → 両刀使い状態に
つまり「手元のみで再現する」謎現象の正体でした。
過去事故との関連:API クライアント二重ロード
実は同じ構造の事故が、過去に別の文脈で起きていました。api_client という API ラッパーが二重ロードされ、認証トークンが片方のインスタンスにしか保存されない問題。
そのときは pytest.ini の python_paths 設定で対応しましたが、根治していなかったわけです。conftest.py 側で sys.path.insert を残していたため、別の文脈で再発しました。
対策3案
案1: conftest.py から sys.path.insert を撤去
最も根治的。CI と同じく PYTHONPATH=$PROJECT_ROOT を手元でも必須とします。
1# .envrc または direnv 等2export PYTHONPATH=$PWDまたは pytest を実行する際に都度指定します。
1PYTHONPATH=. pytestメリット: import の両刀使いが消える、CI と完全に同じ挙動。
デメリット: 手元の利便性が落ちる。.envrc を毎回 source する必要が出る。
案2: pytest.ini で pythonpath を指定
pytest 7.0+ で導入された pythonpath 設定を使います。
1[pytest]2pythonpath = .これは sys.path.insert と等価ですが、pytest が起動時に1回だけ追加するため、sys.modules の重複を避けやすいです。
メリット: conftest.py を綺麗に保てる。
デメリット: pytest 7.0 以降のみ。古いバージョンでは使えない。
案3: 絶対 import を強制する lint
conftest.py の sys.path.insert は残しつつ、絶対 import のみを許可する lint ルールを追加します。
1[flake8]2banned-modules =3 foo: Use 'mymodule.foo' insteadメリット: 既存の利便性を保てる。
デメリット: 完全には防げない(lint をすり抜ける書き方がある)。
採用案と運用ルール
最終的に採用したのは 案1 + 案3 の併用でした。
conftest.pyからsys.path.insertを撤去(案1)direnvで.envrc自動 source を全開発者に必須化- 加えて、絶対 import のみを許可する lint ルール(案3)を pre-commit に組み込み
これにより、
- 二重ロードが構造的に発生しない
- 万一相対 import が混入しても lint で検出
- CI と手元の挙動が完全に一致
教訓:「便利な省略」を疑う
sys.path.insert は「手元で楽に動く」ための便利な省略です。しかし、
- CI と手元で挙動が分岐する
- import の両刀使いが静かに発生
- Mock が効かない・モジュール変数が共有されない、などの謎挙動を引き起こす
「楽をするコード」が長期的にバグの温床になる典型例でした。CI と手元の挙動を 完全に一致させる設計を最優先にすべきです。
チェックリスト
conftest.py を書く・読むときの確認項目です。
sys.path.insertしていないか- している場合、CI 環境の
PYTHONPATHと整合しているか - テスト実行時に
sys.modulesに同名の別オブジェクトが登録されていないか - Mock が効かない現象が出ていないか
- CI と手元で挙動が完全に一致するか
まとめ
conftest.pyのsys.path.insertは import の両刀使いを生む- CI で
PYTHONPATHが設定されていると no-op になり、手元のみで再現する - 案1(撤去)+ 案3(lint)で根治
- 「便利な省略」が静かなバグの温床になる典型例
pytest が手元と CI で違う挙動をしたら、まず conftest.py の sys.path 操作を疑います。