45395 - シコウサクゴ -

conftest.pyのsys.path.insertが招くimport両刀の罠:CIと手元で挙動が違う原因

2026-04-26
インフラ
インフラ運用
Python
pytest
テスト設計
Last updated:2026-05-07
8 Minutes
1429 Words

pytest の conftest.pysys.path.insert(0, PROJECT_ROOT) を書くのは、テストを手元で動かすための定番テクニックです。しかしこれが、CI で再現できない静かなバグの温床になります。

具体的には、mymodule.foofoo(プロジェクトルートからの相対)の 両方の import パスが成立 してしまう。同じモジュールが二重ロードされ、Mock が片方にしか効かないという現象に出会いました。本記事はその構造と回避策です。

状況:CI で通ったテストが手元で謎の挙動

ある日、ある単体テストが CI でパスしているのに、手元で実行すると以下のような現象に出会いました。

test_foo.py
1
from unittest.mock import patch
2
3
@patch('mymodule.foo.api_client')
4
def test_foo(mock_client):
5
from mymodule import foo
6
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.foofoo という2つの別オブジェクトとして import されていました。

1
>>> import sys
2
>>> 'mymodule.foo' in sys.modules
3
True
4
>>> 'foo' in sys.modules # こちらも True!
5
True
6
>>> sys.modules['mymodule.foo'] is sys.modules['foo']
7
False # ← 別オブジェクト

つまり、

  • @patch('mymodule.foo.api_client')mymodule.fooapi_client を Mock 化
  • 実行時のコードは foo モジュール(別オブジェクト)の api_client を呼んでいる
  • Mock が効かない

元凶:conftest.py の sys.path.insert

ナイーブに書かれた conftest.py は以下のような形をしています。

_myProject/conftest.py
1
import sys
2
from pathlib import Path
3
4
PROJECT_ROOT = Path(__file__).parent
5
sys.path.insert(0, str(PROJECT_ROOT))

これにより、PROJECT_ROOT 配下のファイルが2通りの方法で import 可能になります。

1
# 方法1: パッケージとしての絶対 import
2
from mymodule.foo import bar
3
4
# 方法2: プロジェクトルートからの相対 import
5
from foo import bar # これも成立してしまう

異なるテストファイルが片方ずつを使うと、sys.modules に両方が登録されます。

なぜ CI では再現しなかったか

CI 環境では PYTHONPATH=$PROJECT_ROOT が事前に設定されており、conftest.pysys.path.insert重複処理として吸収されていました。

  • CI: sys.path = [PROJECT_ROOT, ...](既に存在)→ conftest.py の insert は no-op
  • 手元: sys.path = [...](PROJECT_ROOT なし)→ conftest.py の insert が効く → 両刀使い状態に

つまり「手元のみで再現する」謎現象の正体でした。

過去事故との関連:API クライアント二重ロード

実は同じ構造の事故が、過去に別の文脈で起きていました。api_client という API ラッパーが二重ロードされ、認証トークンが片方のインスタンスにしか保存されない問題。

そのときは pytest.inipython_paths 設定で対応しましたが、根治していなかったわけです。conftest.py 側で sys.path.insert を残していたため、別の文脈で再発しました。

対策3案

案1: conftest.py から sys.path.insert を撤去

最も根治的。CI と同じく PYTHONPATH=$PROJECT_ROOT手元でも必須とします。

Terminal window
1
# .envrc または direnv 等
2
export PYTHONPATH=$PWD

または pytest を実行する際に都度指定します。

Terminal window
1
PYTHONPATH=. pytest

メリット: import の両刀使いが消える、CI と完全に同じ挙動。

デメリット: 手元の利便性が落ちる。.envrc を毎回 source する必要が出る。

案2: pytest.ini で pythonpath を指定

pytest 7.0+ で導入された pythonpath 設定を使います。

pytest.ini
1
[pytest]
2
pythonpath = .

これは sys.path.insert と等価ですが、pytest が起動時に1回だけ追加するため、sys.modules の重複を避けやすいです。

メリット: conftest.py を綺麗に保てる。

デメリット: pytest 7.0 以降のみ。古いバージョンでは使えない。

案3: 絶対 import を強制する lint

conftest.pysys.path.insert は残しつつ、絶対 import のみを許可する lint ルールを追加します。

.flake8
1
[flake8]
2
banned-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.pysys.path.insert は import の両刀使いを生む
  • CI で PYTHONPATH が設定されていると no-op になり、手元のみで再現する
  • 案1(撤去)+ 案3(lint)で根治
  • 「便利な省略」が静かなバグの温床になる典型例

pytest が手元と CI で違う挙動をしたら、まず conftest.py の sys.path 操作を疑います

Article title:conftest.pyのsys.path.insertが招くimport両刀の罠:CIと手元で挙動が違う原因
Article author:45395
Release time:2026-04-26

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

フィードバックを送る