😕

pytestした時にModuleNotFoundErrorが出る時の原因と対処法

2020/11/19に公開1

背景

Pythonでは大体pytestを使うのですが、序盤でよくこけるけど、毎回原因を忘れてしまって思い出すまでに時間がかかって困る以下のpytest利用時のエラーについての備忘録を書いておきます

E ModuleNotFoundError: No module named 'hogehoge'

現状

ここでは以下のディレクトリ構成のプロジェクトを想定しています。

.
├── poetry.lock
├── pyproject.toml
├── src
│   └── mypkg
│       └── func.py
└── tests
    └── test_mypkg
        └── test_app.py

pyproject.tomlファイルは以下の通り

[tool.poetry]
name = "pytest_test"
version = "0.1.0"
description = ""
authors = ["hogehoge"]

[tool.poetry.dependencies]
python = "^3.7"

[tool.poetry.dev-dependencies]
pytest = "^5.4.1"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

func.pyがこちら

def app_function():
    return 1

test_app.pyがこちら

import pytest
from src.mypkg.func import app_function


def test_func():
    assert app_function() == 1

事象

上記のままでpytestを実行してみると以下のエラーが出ます。

$ poetry run pytest
...
_______________________________________________________________________ ERROR collecting tests/test_app.py _______________________________________________________________________
ImportError while importing test module '/hogehoge/pytest_test/tests/test_app.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
tests/test_app.py:2: in <module>
    from src.func import app_function
E   ModuleNotFoundError: No module named 'src'

解決策

testsディレクトリとtestsディレクトリより下のテストコードが配置されているディレクトリにそれぞれ__init__.pyを追加すれば解決します。具体的には以下のディレクトリ構成にすれば解決します。

.
├── poetry.lock
├── pyproject.toml
├── src
│   └── mypkg
│       └── func.py
└── tests
    ├── __init__.py
    └── test_mypkg
        ├── __init__.py
        └── test_app.py

toxのようなツールを利用している場合は、この方法だと問題があるようなのですが、今回はそこについては記載しないです。その説明は以下が詳しいので、そちらにお任せします。

pytestの使い方と便利な機能

この挙動の原因

この挙動になっているのはpytestの仕様により、testsディレクトリと実行したいテストファイルの存在するディレクトリに__init__.pyがないと sys.path にpytest-testディレクトリが追加されないため、pytestコマンドの実行場所からだとsrcパッケージが参照できないからです。

公式のドキュメント曰く、pytestの以下のような仕様になっているようです。

If pytest finds an “a/b/test_module.py” test file while recursing into the filesystem it determines the import name as follows:

determine basedir: this is the first “upward” (towards the root) directory not containing an __init__.py. If e.g. both a and b contain an __init__.py file then the parent directory of a will become the basedir.
perform sys.path.insert(0, basedir) to make the test module importable under the fully qualified import name.
import a.b.test_module where the path is determined by converting path separators / into “.” characters. This means you must follow the convention of having directory and file names map directly to the import names.

要するに、pytestがやっていることは以下になります。(読み違ってなければ)

  • pytest がファイルシステムに再帰的に探索して、 "pytest_test/tests/test_mypkg/test_app.py" テストファイルを探す
  • sys.path に追加する basedir を決める
    • pytest_test/tests/test_mypkg/test_app.pyから __init__.pyを含まない最初のディレクトリが見つかるまで、どんどんディレクトリの階層を上がっていく。
    • __init__.py を含まない最初のディレクトリが見つかったら、そこがbasedirになる。
    • 例: pytest_testに__init__.pyがなく、testsとtest_mypkgの両方に__init__.pyファイルがある場合、testsの親ディレクトリが basedir になる。
      • したがって、testsに__init__.pyがなく、test_mypkgにだけ__init__.pyがある場合は basedir はtestsディレクトリになるので、srcはimportできない。
  • sys.path.insert(0, basedir)で basedir を sys.path に追加する
    • 例: pytest_testディレクトリがimportされるので、srcがimportできるようになる
  • import tests.test_mypkg.test_appでテストコードをimportする
    • パスの区切り文字/を". "に変換してパスを決定。
      • tests/test_mypkg/test_app.py -> tests.test_mypkg.test_app

こういうimportをしている理由は、私は大規模なプロジェクトの経験がないので、あまりよくわかってませんが、大規模なプロジェクトでは複数のテストモジュールが互いにimportされる可能性があるらしく、正規のimport名を導出することで、テストモジュールが二重にimportされるなどの事態を避けられるからだそうです。

python -m pytestでやると__init__.pyがなくても上手くいく理由

調べているうちに以下のような質問を見つけたので、検証してみます。

pytestをする上での__init__.pyの必要性

検証してみると確かに__init__.pyができました。

poetry run python -m pytest
============================================================================== test session starts ===============================================================================
platform darwin -- Python 3.7.6, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /hogehoge/pytest_test
collected 1 item                                                                                                                                                                 

tests/test_mypkg/test_app.py .                                                                                                                                             [100%]

=============================================================================== 1 passed in 0.02s ================================================================================

ただし、これはカレントディレクトリがpytest_testディレクトリである場合のみに限ります。その証拠にtestsディレクトリに移動してからpoetry run python -m pytestするとModuleNotFoundErrorが発生します。

Python 3.3以降では、__init__.pyが存在しない場合でもパッケージとして認識できることが原因でした。

こうなる理由は質問者が回答で補足している通り、上記が原因っぽいです。

このパッケージとして認識できるというのはPython2.7とPython3.7でsrcパッケージをimportした時の挙動が参考になるかと思います。

Python2.7だと以下のようにエラーが出ます。これはPython3.3より前のバージョンでは__init__.pyが置いていないディレクトリをパッケージとして認識しない仕様になっているためです。

$ python
Python 2.7.16 (default, Feb 29 2020, 01:55:37) 
[GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.29.20) (-macos10.15-objc- on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import src
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named src

一方Python3.7だと__init__.pyが存在しない場合でもパッケージとして認識できるので、以下のようにエラーが出さずにimportすることができます。

$ python3
Python 3.7.6 (default, Dec 30 2019, 19:38:26) 
[Clang 11.0.0 (clang-1100.0.33.16)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import src

では、なぜこの使用により、カレントディレクトリがpytest_testディレクトリであれば、本題のpython -m pytestは上手くテストできるのでしょうか?

poetry run python -m pytestで呼び出されるのはこちらになるのですが、この場合はpytest_testディレクトリでpythonを起動している状態になります。

したがって、Python3.3以降の仕様により、その配下のパッケージを__init__.pyなしでもimportすることができますので、pytest.main()を実行した時にimport srcが出てきてもimportすることができます。

一方、poetry run pytestで呼び出されるのはwhichしたパスを開いてみると以下のファイルが記載されています。

#!/hogehoge/pytest_test/.venv/bin/python3.7
# -*- coding: utf-8 -*-
import re
import sys

from pytest import main

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

ここにprint(__file__)をつけて実行すると以下で実行されていることが分かります。poetry run pytestでは以下の場所で実行されているため、実行されているディレクトリの配下にsrcのディレクトリがありません。

そして、__init__.pyが存在しない状態だと、srcのディレクトリが実行されているディレクトリの配下になく、前述のpytest.main()の処理でsys.path.insert(0, basedir)が行われないので、srcが読み込めず、エラーになっています。sys.path.insert(0, '/hogehoge/pytest_test/')を追加してあげるとエラーが出ずに正常に進むことは確認したので、おそらく間違ってないと思います。

/hogehoge/pytest_test/.venv/bin/pytest

終わりに

今回は気分が乗ったので、やや深めに調べたおかげで、pytestではなんで__init__.pyがないと動かないのかが分からなかったのですが、なんとなく理解できたのでよかったです。

参考文献

pytest入門 - 闘うITエンジニアの覚え書き

Good Integration Practices

python -mのドキュメント

Discussion

KAZYPinkSaurusKAZYPinkSaurus

どなたか存じ上げませんが大変参考になりました。
ありがとうございました。