設定ファイルを使ったPytestのimportError対策
こんにちは、ひぐです。
Pytestを実行した際に、パッケージのimportがうまくいかず、ハマってしまいました。
また解決策を調べると、複数の方法が出てきて混乱してしまったので、この記事では自分が一番しっくりきた設定ファイルを使ったimport方法をまとめておこうと思います。
まだ、Pytest使い始めたばかりなので考慮漏れ等あるかもしれないです。なにかあれば更新していこうと思います🙏
前提
m1 macbookでpoetryを使った状態で実験しています。
pyproject.toml
[tool.poetry]
name = "my_package"
version = "0.1.0"
description = ""
authors = ["kokoro higuchi"]
[tool.poetry.dependencies]
python = "^3.9"
numpy = "^1.24.2"
pandas = "^1.5.3"
[tool.poetry.dev-dependencies]
pytest = "^7.2.1"
black = "^23.1.0"
mypy = "^1.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
また、今回に使ったコードはgithubにアップロードしています。
やりたいこと
下記のようにmy_packageのテストコードをtests dirに切り出した状態でpytestを実行したい。
.
├── poetry.lock
├── pyproject.toml
├── src
│ └── my_package
│ └── greet.py
└── tests
└── test_greet.py
このときに、なにも設定をせずに下記のようなテストコードを実行(poetry run pytest
)するとimportエラーが出てしまう。
from my_package.greet import hello
def test_hello():
assert hello() == "Hello."
============================= test session starts ==============================
platform darwin -- Python 3.9.13, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/kokoro/ghq/github.com/kokoro/pytest_tutorial
collected 0 items / 1 error
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_greet.py _____________________
ImportError while importing test module '/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/tests/test_greet.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/opt/homebrew/Cellar/python@3.9/3.9.13_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/importlib/__init__.py:127: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
tests/test_greet.py:4: in <module>
from my_package.greet import hello
E ModuleNotFoundError: No module named 'my_package'
これを解決したい。
解決策
pytestの設定ファイル[1]にpythonpath
, testpaths
を追記します。例えばpyproject.tomlでは下記のように書くと、importエラーが解消されテストが完了します。
[tool.pytest.ini_options]
pythonpath = "src"
testpaths = ["tests",]
poetry run pytest
========================================= test session starts =========================================
platform darwin -- Python 3.9.13, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/kokoro/ghq/github.com/kokoro/pytest_tutorial, configfile: pyproject.toml, testpaths: tests
collected 1 item
tests/test_greet.py .
解説
なぜ、これで解決するのかを解説します。
pytestはデフォルトではsys.path
に格納されているパスリストをつかって、モジュールやテストを検索します。
設定ファイルで、pythonpath
やtestpaths
にパスを指定すると、それらがsys.pathに追記され、テスト実行時にモジュールをimportできるようになります。
下記のようにsys.pathをprintするとその内容が理解できます。
def test_hello():
import sys
from pprint import pprint
pprint(sys.path)
assert hello() == "Hello."
['/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/tests', # testpathで追加
'/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/src', # pythonpathで追加
'/opt/homebrew/bin',
'/opt/homebrew/Cellar/python@3.9/3.9.13_1/Frameworks/Python.framework/Versions/3.9/lib/python39.zip',
'/opt/homebrew/Cellar/python@3.9/3.9.13_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9',
'/opt/homebrew/Cellar/python@3.9/3.9.13_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/lib-dynload',
'/Users/kokoro/Library/Python/3.9/lib/python/site-packages',
'/opt/homebrew/lib/python3.9/site-packages',]
pytestの設定オプション
上記の解決策に関連するpytestの設定オプションを解説します。
rootdir
rootdirはpytestが基準とするディレクトリです。基本的には、pyproject.toml
等の設定ファイルが置いてあるところがrootdirとなります。pytest --rootdir='hoge'
のように明示的に指定することも可能です。
pyproject.toml
に記載したpythonpath
などは相対パスですが、
sys.pathのリストには'/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/tests'
とフルパスが追加されています。
この/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/
はpytestのrootdirです。
rootdirは(簡単に説明すると[2])pytest実行時にカレントディレクトリ, もしくは指定した(ファイル・ディレクトリ)から設定ファイルが見つかるまで親にさかのって決定されるため、カレントディレクトリが設定ファイル以下であれば、どこであっても同じrootdirが設定されます。
testpaths
pytestの設定項目で、テスト対象のディレクトリを指定できます。rootdirからの相対パスを指定する必要があります。
pythonpath
pytestの設定項目でimport対象のディレクトリを指定できます。rootdirからの相対パスを指定する必要があります。
この設定項目はpytest7系から追加されました。7系以前の場合は、
https://pypi.org/project/pytest-srcpaths/ をinstallすることで利用可能になります。
その他の方法との比較
この解決策以外にもsys.pathに追加する方法はいくつかあるので、比較します。
conftest.pyにsys.path.append()を追記する
下記のように、conftest.pyにsys.path.append()を追加することでもテスト実行前にsys.pathが改変され、import可能になります。
import sys
sys.path.append('/Users/kokoro/ghq/github.com/kokoro/pytest_tutorial/tests')
ただし、conftest.py
はフィクスチャとフック関数を格納するためのファイルです。この方法はPytestが意図してない利用法なため、避けたほうが良いでしょう。
pip install -e .
でパッケージをinstallする
pytest実行前にpip install -e .
を使って、パッケージをeditableモードでinstallするとパッケージのimportが可能になります。PytestのGood Integration Practicesで紹介されている方法です。
本記事で紹介した方法は、pytest以外も参照するsys.pathを書き換えているので思わぬ副作用を与える可能性があります。一方でこの方法であればその心配はありません。
自分はこの方法と比較した上で、今回紹介した方法を利用しようと考えました。設定ファイルに変更内容がすべて記載されていて見通しが良いこと、Pytest実行前に別の依存するコマンドを実行しなくて良いというメリットが、sys.pathの改変というデメリットを上回ると考えたためです。
その他関連話題
pytestのimport mode
pytestは歴史的背景からデフォルトではprepend
と呼ばれるimport modeが設定されていますが、新規プロジェクトでは下記のようにimportlib
を指定することを推奨しています。
[tool.pytest.ini_options]
addopts = [
"--import-mode=importlib",
]
Good Integration Practicesによると、従来のprepend
modeだと、sys.pathを改変するので、toxでインストールしたバージョンのパッケージをテストしたいときに、ローカルのパッケージを優先してしまうという問題がある、とのことです。(ちょっとこのあたり解釈自信ないです...🙏)。importlib
ではsys.pathは改変しません。
自分はtoxや同名のテストモジュールを利用していないので、prepend
でとくに問題になりませんが、利用する際はより注意したいと思います。
パッケージのルート以外からのimportを避ける
今回の方法では、パッケージ内において、パッケージのルート以外からのモジュールのimportをしているとpytest側から呼び出すことができません。
たとえば、下記のようなディレクトリ構造でdoble_greet.py
がgreet
をfrom my_package.greet import hello
でなくfrom greet import hello
とimportしているとエラーが起きます。
├── poetry.lock
├── pyproject.toml
├── src
│ ├── __init__.py
│ └── my_package
│ ├── doble_greet.py
│ └── greet.py
└── tests
└── test_greet.py
from greet import hello # ImportError
def double_hello():
return hello() * 2
if __name__ == "__main__":
print(double_hello())
これは、sys.pathにsrc
は追加されるものの、src/my_package
は追加されないためです。
importErrorを回避するためにも、importのルールを画一化するためにも、モジュール内におけるimport文はなるべくパッケージのルートからしたほうが良いのかなと思っています。
おわり
設定ファイルを使ったPytestのパッケージimportについて紹介しました。Pythonのパッケージ回りはPython自体の歴史が深いこともあって、なかなか難しいと感じました。
調べる中でなあなあに理解してたPythonのimportまわりや、__init__.py
とかsetuptools
などについて少し知識がつけられたのは良かったです。toxを使うようになったり、巨大なライブラリでテストを書くときには、また違ったベストプラクティスがあるかもしれないので、このあたりも経験したら追記したいと思います。
参考資料
Discussion