❇️

設定ファイルを使ったPytestのimportError対策

2023/02/12に公開

こんにちは、ひぐです。

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にアップロードしています。
https://github.com/zerebom/pytest_import_tutorial

やりたいこと

下記のように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に格納されているパスリストをつかって、モジュールやテストを検索します。
設定ファイルで、pythonpathtestpathsにパスを指定すると、それらが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で紹介されている方法です。

https://docs.pytest.org/en/latest/explanation/goodpractices.html#choosing-a-test-layout-import-rules

本記事で紹介した方法は、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によると、従来のprependmodeだと、sys.pathを改変するので、toxでインストールしたバージョンのパッケージをテストしたいときに、ローカルのパッケージを優先してしまうという問題がある、とのことです。(ちょっとこのあたり解釈自信ないです...🙏)。importlibではsys.pathは改変しません。

自分はtoxや同名のテストモジュールを利用していないので、prependでとくに問題になりませんが、利用する際はより注意したいと思います。

https://docs.pytest.org/en/latest/explanation/goodpractices.html#choosing-an-import-mode

パッケージのルート以外からのimportを避ける

今回の方法では、パッケージ内において、パッケージのルート以外からのモジュールのimportをしているとpytest側から呼び出すことができません。

たとえば、下記のようなディレクトリ構造でdoble_greet.pygreetfrom 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を使うようになったり、巨大なライブラリでテストを書くときには、また違ったベストプラクティスがあるかもしれないので、このあたりも経験したら追記したいと思います。

参考資料

https://zenn.dev/pesuchin/articles/9573476d53d234f09433

https://zenn.dev/panyoriokome/articles/f34ae72cc33150

https://www.shoeisha.co.jp/book/detail/9784798177458

脚注
  1. pytestの設定は、pytest.ini, setup.cfg, tox.ini, pyproject.tomlに記載できるが、今回は、poetryをつかっているので、pyproject.tomlを利用。 ↩︎

  2. rootdirの探索方法の詳細はhttps://docs.pytest.org/en/7.1.x/reference/customize.html#finding-the-rootdir に記載されてます。 ↩︎

Discussion