プログラミング初心者がアーキテクトっぽく語る

見苦しい記事も多数あるとは思いますが訂正しつつブログと共に成長していければと思います

PyCharmでテストコードのフォルダを多階層にしたらテストが動かなくなった

PyCharm + unittestでTDDっぽいことを試しました。 最後にテストコードのフォルダ階層をプロダクションコードのフォルダ階層と一致させたらそれまで動いていた単体試験の大半が動かなくなってしまいました。

ただのsys.pathの理解不足だったのですが同じことで困っている人がいるかもしれなので投稿します。


コード完成

PyCharmでTDDっぽくコードを作成し、完成させました。

そのときの構成は以下のような感じです。__init__.pyは省略してます。

myproject/
├── packageA
│   ├── subpackageA
│   │   └── moduleA.py
│   └── subpackageB
│       └── moduleB.py
└── tests
    ├── test_moduleA.py
    └── test_moduleB.py
    └── test_data.json

プロダクションコードはmyproject/packageA/に置き、テストコードとテストデータはmyproject/tests/に置きました。

プロダクションコードはさらにsubpackageAとsubpackageBというサブパッケージに分類した一方、テストコードはフラットなままです。


テストコードのフォルダ階層を変更

testsフォルダには1階層しかありません。 これで困っていたわけではありませんがJavaっぽくテストコードをプロダクションコードと同じ多階層構造にしようと思いました。

Javaではテストコードをプロダクションコードと同じ構成にすることが多いと思います。 というかJavaIDEがそれを推奨してくることが多いと思います。

変更の結果がこちらです。

myproject/
├── packageA
│   ├── subpackageA
│   │   └── moduleA.py
│   └── subpackageB
│       └── moduleB.py
└── tests
    ├── packageA
    │   ├── subpackageA
    │   │   └── test_moduleA.py
    │   └── subpackageB
    │       └── test_moduleB.py
    └── test_data.json

myproject/tests/配下の構造がプロダクションコードと同じになりました。 これでどのプロダクションコードのテストがどこにあるかひと目でわかるようになりました。


単体試験が動かない!

しかしさっきまで動いていた単体試験の大半が動かなくなってしまいました。理由はimportエラーです。 テストコードtest_moduleA.pyがプロダクションコードpackageA.subpackageA.moduleAをimportできません。

かなり悩みましたが問題はsys.pathでした。


相対パスはない

プロダクションコードは動くのに単体試験でプロダクションコードのimportエラーが出るとき真っ先に疑うべきは相対パスです。

プロダクションコードでもテストコードでも相対パスは使うべきではありません。 理由は他の方々が詳しく書いてくれています。

https://qiita.com/jesus_isao/items/f93c11248192645eb25d

https://comeroutewithme.com/2018/02/24/python-relative-imports-and-unittests-derp/

なお私はこれには該当しませんでした。


sys.pathを表示

importエラーで悩んだとき、次にすべきことはsys.pathを表示することです。 以下の文をimportエラーが発生しているテストコードtest_moduleA.pyに仕込んでsys.pathを表示しました。

import sys
print(sys.path)

その結果がこちらです。 見やすいように改行と行番号を入れてます。

1:  '/Users/john/PycharmProjects/myproject/tests',
2:  '/Users/john/PycharmProjects/myproject',
<以下省略>

計8個のパスがありましたが、3個目以降はビルトインライブラリやpip installで追加したライブラリへのパスなので今回のimportエラーとは関係ありませんでした。 残った1つ目と2つ目のパスだけ深堀りしていきましょう。


1つ目のパスを調査

Pythonはmoduleを探すとき、sys.pathを上から検索していきます。 まずは1つ目のパスから見てみましょう。

1つ目のパスはmyproject/testsです。 これはテストコードがあるフォルダです。 PythonはpackageA.subpackageA.moduleAを探すとき、まずこのフォルダの中を検索します。 packageA.subpackageA.moduleAはプロダクションコードですので、テストコードの中からは発見できないはずですがエラーが出ている以上、一応、机上で試してみましょう。

myproject/testsの中は以下のような構成です。 Pythonの気持ちになってこの中からpackageA.subpackageA.moduleAを探してみます。 勿論、ないはずですけど。

packageA
 ├── subpackageA  <<< !!!
 │    └── test_moduleA.py
 └── subpackageB
      └── test_moduleB.py

あ、、、packageA.subpacakgeAってある。


勘違い発覚

ひょっとして、、、プロダクションコードpackageA.subpackageAと勘違いしてテストコードtests.packageA.subpacakgeAをimportしようとしてる?

その結果「tests.packageA.subpacakgeAにはmoduleAがなかった!」と文句を言っている?

確かにtests.packageA.subpacakgeAは、1つ目のパス(myproject/tests)から辿るとpackageA.subpacakgeAになり、importしたいパッケージと同名ですから勘違いするのも無理はありません。

そしてその中にはtest_module.pyしかないので、「moduleAがない!」と文句を言うのは当然です。


原因

Pythonでは、以下の条件が満たされるとテストコードをプロダクションコードと混同してしまい、プロダクションコードのモジュールが発見できなくなる可能性があります。

  • プロダクションコードとテストコードのパッケージ名が一致している
  • テストコードのパスがプロダクションコードのパスより先にsys.pathの中に入っている

sys.path調査

今回の件でsys.pathを意識することの重要性を痛感しました。

sys.pathには通常、ビルトインライブラリやpip等で追加したライブラリへのパスが入ります。 他にどのようなパスが入るか調べてみました。

1. ターミナルからPythonを実行したときのsys.pathの調査

ターミナルからPythonコマンドで実行した場合、current directoryがsys.pathに入りました。

2. PyCharmで単体試験を実行したときのsys.pathの調査

PyCharmで実験したところ以下のフォルダを含めるように見えました。

  • プロジェクトフォルダ
  • current directory(単体試験を実行開始したフォルダ)
  • Sources Rootにマークしたフォルダ
  • Test Sources Rootにマークしたフォルダ

sys.pathの追加方法

次はsys.pathにパスを追加する方法を調べました。

1. sys.path.append()

sys.pathはリストですのでsys.path.append()でパスを追加することができます。 この方法で追加したパスはそのスクリプトの中でのみ有効です。

import sys
sys.path.append("追加したいパス")

2. PYTHONPATH

環境変数PYTHONPATHに設定したパスがsys.pathに追加されます。

export PYTHONPATH=追加したいパス

3. .pthファイル

.venv/lib/python3.13/site-packagesなどに*.pthファイルを作成し、追加したいパスを記述します。 *.pthファイルを作成するところはsys.pathが通っているフォルダならどこでもOKですが、*/site-packages/に作成するのが一般的です。

4. PyCharmのSources Root、Test Sources Root

PyCharmでSources Root、Test Sources RootにMarkしたフォルダがsys.pathに入ります。 パスの順序を自分で選ぶことはできません。


考察

PyCharmでSources Root、Test Sources RootをMarkすればプロダクションコードとテストコードのrootフォルダがsys.pathに入ります。 通常、これでパスは十分です。 実際、今回の問題の原因はパスが足りなかったことではありません。

今回の問題は、プロダクションコードとテストコードのパッケージ名が一致した結果、単体試験においてモジュール検索に混乱が生じたことです。 sys.pathの順序を工夫すれば避けられそうですが、それは不安定な解決策に思えます。 テストコードのパッケージ名の前後に"test_"や"_"などを付与した方がよいかもしれません。


学び

importは必ずフルパスを指定しましょう。

プロダクションコードとテストコードのrootにパスが通るように、PyCharmでSources Root、Test Sources Rootの設定をしましょう。

Javaのようにプロダクションコードとテストコードでパッケージ構成を一致させると動かくなる可能性があります。 テストコードのパッケージ名の前後に"test_"や"_"を付与しましょう。

それでも困ったときはsys.pathを表示して調査しましょう。

これらの学びを活かしたPythonプロジェクトのセットアップがこちらです。 興味があれば参照ください。

architecting.hateblo.jp