先日、以下の記事でpythonをインストールしてから開発を進め、今回はunittestモジュールを用いてユニットテストをする段階になりました。
後述するディレクトリ構成でファイルを配置しましたが、理解が足りず、別ディレクトリにあるテストコードから自作モジュールのimportがうまく出来ないでいました。
そこで「*.pth」ファイルを配置することでpythonのモジュール検索パスに任意のディレクトリを追加できることを知って、それを利用すると別ディレクトリからimportが可能になりました。
結論を先に言うと、追加したいモジュール検索パスを/path/to/module
とした場合に、以下のコマンドを打つとパスが通りました。
$ echo /path/to/module > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*site-packages")/module.pth
私の環境は以下の通りです。
環境 | バージョン |
---|---|
MacOS | 10.13.2 |
python | 3.6.4 |
以下で具体例を交えて、詳しく書いていきます。
目次
今回構築したディレクトリ・ファイル構成
以下で示すのはサンプルコードですが、ソースコードとそれに対応するテストコードを1対1で対応させるために、ソースコードがあるmainディレクトリと、テストコードがあるtestsディレクトリは同じ構成にしました。
/opt
└── app
├── main
│ ├── business
│ │ └── calc.py
│ └── main.py
└── tests
└── business
└── test_calc.py
ファイル名 | 役割 |
---|---|
main.py | このアプリケーションで最初に実行されるコード(この記事上では実行に使用してません) |
calc.py | main.pyによって使用されるCalcクラスのコード |
test_calc.py | calc.pyをテストするコード |
以下はそれぞれのコードの内容です。
from business.calc import Calc
## Calcクラスのaddメソッドを利用する
calc = Calc()
print(calc.add(2 + 3))
class Calc:
## 引数を足し算して返すだけのaddメソッド
def add(self, a, b):
return a + b
test_calc.py
でcalc.py
をimportする時に、main.py
と同じ記述(from business.calc import Calc
)でimportした方が直感的にわかりやすいと思い、この形式で記載したテストコードを実行しました。
import unittest
from business.calc import Calc ## main.pyと同じ記述でimportしたい
class TestCalc(unittest.TestCase):
def setUp(self):
self.__calc = Calc()
## Calcクラスのaddメソッドをテストする
def test_add_ok(self):
self.assertEqual(self.__calc.add(2, 3), 5) ## 2 + 3 = 5 (正)
def test_add_fail(self):
self.assertEqual(self.__calc.add(2, 3), 6) ## 2 + 3 = 6 (誤)
当初、/opt/app/test
ディレクトリを作ろうとしましたが、python標準で同名のtestモジュールが既に存在するので、python公式ドキュメントを参考に、それと混在して不具合が起きないように末尾にs
をつけてtests
と命名しました。
$ python -c 'import test; print(test)'
<module 'test' from '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/test/__init__.py'>
$
別ディレクトリから自作モジュールのimportに失敗した
しかしこのままテストコードtest_calc.py
を実行すると、「businessモジュール」が見つからず、ModuleNotFoundError
という実行時エラーになりました。
$ python -m unittest /opt/app/tests/business/test_calc.py
E
======================================================================
ERROR: test_calc (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: test_calc
Traceback (most recent call last):
File "/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/unittest/loader.py", line 153, in loadTestsFromName
module = __import__(module_name)
File "/opt/app/tests/business/test_calc.py", line 2, in <module>
from business.calc import Calc # main.pyと同じ記述でimportしたい
ModuleNotFoundError: No module named 'business'
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
$
ModuleNotFoundError
が出ている原因
Python公式ドキュメントによると、モジュールをimportする時に、以下の順番でモジュールが検索されます。
- ビルトインモジュールにあるか探す
- sys.pathに格納されているディレクトリにあるか探す。
デフォルトの状態だと、自作のbusinessモジュールはどちらにも該当していません。よって/opt/app/tests
配下にあるtest_calc.py
から、/opt/app/main
配下にある「businessモジュール(calc.py
があるディレクトリ)」が見えないため、ModuleNotFoundError
が出ていました。
モジュール検索パスを追加する
以下では自作モジュールへのパスを、前述した2のsys.pathにモジュール検索パスとして追加することで、先ほどのコードやディレクトリ構成は変えずに、モジュールの名前解決ができるようにします。
「*.pth」ファイルを使ってパスを通す方法
以下のコマンドでsys.pathにデフォルトで格納されたモジュール検索パスを確認できます。(見やすいようにsedコマンドで改行表示しています。)
$ python -c 'import sys; print(sys.path)' | sed s/,/,\\$'\n'/g
['',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python36.zip',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/lib-dynload',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages']
$
python公式ドキュメントによると、上記で表示された中の*/site-packages
のディレクトリに*.pth
というファイルを置くと、その中に書かれたパスがpython実行時にモジュール検索パスとして追加されます。その結果、そのパスに配置されたモジュールをどのファイルからでもimportできるようになります。
「*.pth」ファイルを配置する
以下のようにすると、例えばapp.pth
を*/site-packages
に配置して、その中に書かれたパス(今回は/opt/app/main
)をsys.pathにモジュール検索パスとして追加することができます。
## app.pthを作成して、モジュール検索パスを追加
$ echo /opt/app/main > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
## 作成したファイルの中身を確認
$ ls -l $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
-rw-r--r-- 1 stedplay _sophos 14 1 22 20:45 /Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages/app.pth
$ cat $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
/opt/app/main
$
## モジュール検索パスが通っていることを確認(見やすいようにsedで改行表示しています)
$ python -c 'import sys; print(sys.path)' | sed s/,/,\\$'\n'/g
['',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python36.zip',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/lib-dynload',
'/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages',
'/opt/app/main']
$
/opt/app/main
にモジュール検索パスを通すことができました。
これで、このディレクトリ配下はどのpyファイルからもimportでアクセスできるようになりました。
別ディレクトリから自作モジュールのimportに成功した
再びテストコードtest_calc.py
を実行すると、実行時エラーは発生せず成功しました。
そして2つのテスト(test_add_ok
、test_add_fail
)の内、テストコードの足し算の結果が誤っていた後者だけがテストに失敗していることを確認できました。
$ python -m unittest /opt/app/tests/business/test_calc.py
F.
======================================================================
FAIL: test_add_fail (opt.app.tests.business.test_calc.TestCalc)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/opt/app/tests/business/test_calc.py", line 13, in test_add_fail
self.assertEqual(self.__calc.add(2, 3), 6) ## 2 + 3 = 6 (誤)
AssertionError: 5 != 6
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
$
Dockerに応用すると
ちなみに私はDockerでpythonの環境構築をしているので、以下のRUNコマンドをDockerfileに記載して、イメージ作成時にモジュール検索パスを追加するようにしています。
FROM python:3.6.4-alpine3.7
## add module search path
RUN echo /opt/app/main > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
...以下略...
まとめ
「*.pth」ファイルを使ってpythonのモジュール検索パスを追加して、任意のディレクトリから自作モジュールをimportする方法をまとめました。
今回のように手間をかけてモジュール検索パスを通さずに、テストコードを自作モジュールが直接見える位置に置くことでも対応可能です。
しかし、時間が経って後で見返したときに、ソースコードとそのテストコードが1対1対応している直感的なディレクトリ構成の方が、その仕組みを私は理解しやすいと思い、あえて今回の構成にしました。