ローカルでは pytest が全部通るのに、CI で ModuleNotFoundError が出る。よく見るとテストの import 文と conftest.py の sys.path 操作 がマッチしていなくて、ローカルだけたまたま通っていた、というケースを何度か踏みました。
最終的に「相対 import 禁止 + sys.path.insert 禁止」を CI で強制する方針に切り替えた記録です。
何が起きるのか
症状
1$ pytest tests/2============ 全 124 テスト pass ============3
4# CI で同じコマンド5$ pytest tests/6ModuleNotFoundError: No module named 'analyzers'ローカル mac でしか再現しない、CI Linux 環境だけ落ちる。
原因の1つ目: conftest.py の sys.path.insert
1import sys2from pathlib import Path3
4sys.path.insert(0, str(Path(__file__).parent.parent / "src"))これがあると tests/ 配下から from analyzers import ... が通ります。しかし このコードが評価されるかどうか は、pytest の起動位置や pyproject.toml の設定に依存します。
1src/2 analyzers/3 __init__.py4tests/5 conftest.py ← sys.path.insert を持つ6 unit/7 conftest.py ← もう一つ別の path.insert を持つ8 test_x.py複数の conftest.py が散在し、それぞれが 微妙に違う path を sys.path に入れる。pytest は 1 番上から順に評価しますが、CI 環境とローカルで cwd が違うと、評価順が変わって動くこともあれば動かないこともある状態に。
原因の2つ目: 相対 import が混ざる
1from ..helpers import make_dummy # 相対 import2
3# tests/integration/test_pipeline.py4from analyzers import aggregator # 絶対 import両方が混在していると、pytest の rootdir がどこかで解釈ミスを起こすと一気に全部 import エラーになります。
解決方針
- 絶対 import に統一する(相対 import を禁止)
- conftest.py の sys.path 操作を禁止する
pyproject.tomlでpythonpathを一元管理する- CI で grep ベースで禁則違反を検出する
pyproject.toml で pythonpath を 1 か所に集約
1[tool.pytest.ini_options]2pythonpath = ["src"]3testpaths = ["tests"]これで pytest 起動時に常に src/ が sys.path に入ります。conftest.py で個別に操作する必要がなくなります。
conftest.py を最小化
1"""共通フィクスチャだけを置く。sys.path 操作はここで一切しない。"""2import pytest3
4@pytest.fixture5def sample_records():6 return [...]sys も pathlib.Path も import しません。何もしていない conftest.py が正しい姿です。
相対 import を禁止する
from .helpers import ... のような相対 import は、ファイル移動時に壊れたり、IDE のリファクタリングが追えなかったりします。絶対 import に統一します。
1# Bad: 相対2from ..helpers import make_dummy3
4# Good: 絶対5from tests.helpers import make_dummytests/helpers.py を作って、そこから絶対パスで import します。
CI で禁則を検知する
人間の規律だけでは戻ります。grep ベースの check を CI に入れました。
禁則 1: conftest.py での sys.path.insert
1#!/bin/bash2set -e3
4if grep -rn "sys.path" --include="conftest.py" .; then5 echo "ERROR: conftest.py で sys.path 操作を行わないでください"6 echo "→ pyproject.toml の [tool.pytest.ini_options] pythonpath を使う"7 exit 18fi9echo "OK: conftest.py に sys.path 操作なし"禁則 2: テストファイルでの相対 import
1#!/bin/bash2set -e3
4# tests/ 配下の .py で `from .` または `from ..` を検索5if grep -rEn "^from \.+ " --include="*.py" tests/; then6 echo "ERROR: tests/ 配下では相対 import を使わないでください"7 echo "→ from tests.helpers import ... のように絶対 import で書く"8 exit 19fi10echo "OK: 相対 import なし"禁則 3: テストファイル冒頭の sys.path 操作
conftest.py だけでなく テストファイル本体で sys.path をいじるパターンも見かけます。これも止めます。
1if grep -rEn "^sys\.path\.(insert|append)" --include="*.py" tests/; then2 echo "ERROR: テストファイルで sys.path 操作を行わないでください"3 exit 14fiCI への組み込み
1- name: Check import hygiene2 run: |3 bash scripts/check_no_sys_path_in_conftest.sh4 bash scripts/check_no_relative_imports.shpytest 実行前にこれが落ちると、import 構造の問題が 実際の test 失敗より先に検知できます。エラーメッセージで「修正方法」までセットで出すと、レビュー時に説明する手間が減ります。
移行作業
1. conftest.py の整理
リポジトリ全体で conftest.py を grep して、sys.path を含むものを順に削除しました。
1grep -l "sys.path" $(find . -name "conftest.py")各ファイルの sys.path.insert を削除し、pyproject.toml の pythonpath で代替できることを確認します。
2. 相対 import の絶対化
1# 検出2grep -rEn "^from \.+ " --include="*.py" tests/地道に書き換えます。from .helpers import x → from tests.helpers import x。
3. テスト全体の再実行
CI で完全にクリーンな環境で pytest を回し、全部通るかを確認します。
1# クリーン環境のシミュレーション2docker run --rm -v $(pwd):/app -w /app python:3.11 \3 bash -c "pip install -e . && pytest tests/"ローカルの「たまたま通っていた」依存関係を排除して検証できます。
学び
1. 「ローカルで通る」は信用できない
conftest.py が複数ある、sys.path を編集している、相対 import が混ざっている、のどれかがある時点で 環境依存の不具合が潜んでいます。pytest は柔軟すぎるので、明示的な制約を入れたほうが堅牢になります。
2. pyproject.toml の pythonpath は強力
pythonpath 1 行で sys.path 操作を全部不要にできます。プロジェクト全体の import の起点が明文化されるので、新しいメンバーが「どこから import するか」で迷わない メリットも大きいです。
3. grep ベースの CI チェックは費用対効果が高い
完全な静的解析より雑ですが、grep で 90% は捕まえられます。実装も 10 行のシェルスクリプトで済みます。正規の lint ツールが対応していない独自規約を強制するときに便利です。
4. 「動いている」状態を 1 度壊す勇気
「conftest.py の sys.path を消したら全部壊れるんじゃ」と思って先延ばしにしていましたが、実際にやってみると 2 時間で完了しました。先延ばしのほうが負債を増やしていました。
まとめ
| 観点 | Before | After |
|---|---|---|
| pythonpath 管理 | conftest.py に分散 | pyproject.toml に一元化 |
| import スタイル | 相対と絶対が混在 | 絶対のみ |
| sys.path 操作 | テストごとに散在 | ゼロ |
| CI 検知 | なし | grep ベースで禁則を強制 |
| ローカル/CI の差異 | 頻発 | 解消 |
CI で謎の ModuleNotFoundError が出るたびに対症療法していると、いつまでも根が抜けません。
pyproject.toml の pythonpath + 絶対 import 統一 + grep ベース CI、この 3 つを揃えると import 関連の不具合がほぼ消えました。「pytest が柔軟すぎて困る」と感じたら、自分たちで明示的な制約を入れるのが解決策でした。