LoginSignup
14
12

More than 3 years have passed since last update.

Pythonでテストコードとテスト対象のコードを別ディレクトリに分けて置いたときに発生するModuleNotFoundErrorと格闘しました

Last updated at Posted at 2019-07-09

これは私がテスト駆動開発を身につけるまでの長い旅の始まり

解決策

アップデート版 解決策 (2019/07/13)

  1. __init__.pyを配置して、testディレクトリ以下をモジュール化
  2. python -m unittestを実行することで、全てのテストが実行されます
.  # カレントディレクトリ
├── practice_test
│   └── tashizan.py
└── test
    ├── __init__.py  # 追加
    └── practice_test
        ├── __init__.py  # 追加
        └── test_tashizan.py

python -m unittestpython -m unittest discover -s . -p test*.pyと同じで
カレントディレクトリ以下のtest*.pyというファイル名のパターンに該当するテストを実行しているそうです。
詳しく知りたい方はドキュメントの「テストディスカバリ」をご参照ください。

以上は、https://twitter.com/kashew_nuts/status/1148671204405891072 にて教えていただきました。
kashew_nutsさんに感謝申し上げます。

アップデート版に対応するソースコードはこちらです。
https://github.com/ftnext/test_first_time/tree/correct-way

以下は、2019/07/09に公開した内容です。
以下の方法でもModuleNotFoundErrorは解決されたのですが、python -m unittestが効かず(__init__.pyがないためと推測)、テストはファイルごとに実行するようになってしまったので、今後はアップデート版を使っていきたいと思います。

当初の解決策 (2019/07/09)

解決手順をざっくり書くと

  1. setup.py, setup.cfgの2つのファイルを作成
  2. pip install -e .を実行

の2ステップです。
この説明でピンと来ない方は「解決するまで」の部分をご確認ください。
(ピンと来る方はこの記事から得られるものは少ないのではないかと思います)

前提

動作環境

  • macOS 10.14.4
  • Python 3.7.3

注意

  • 絶対パスを表示する場合、PCのユーザ名は...で置き換えています
  • 筆者はPythonを1年半ほど使っていますが、今回パッケージのimport周りの理解が甘かったことを痛感しました。記載に誤り等ありましたらご指摘いただけますと助かります。

背景

unittestの使い方に習熟するべく、Qiita記事(1)「Python標準のunittestの使い方メモ」の写経に取り組んでいました。
元の記事(1)はPython2系で動かしていますが、手元ではPython3系で動かしました。

元の記事(1)が想定していると思われるファイル配置は以下です。

.
├── tashizan.py
└── test_tashizan.py

「テストコードとテスト対象のコードを同じ階層に置かずに、それぞれディレクトリに分けて配置したい」と思い、以下のようなディレクトリ分割を試してみることにしました。

.  # カレントディレクトリ
├── practice_test  # テスト対象のPythonのファイルを置く
│   └── tashizan.py
└── test  # テストを置く
    └── practice_test  # practice_test内のPythonファイルのテストを置く
        └── test_tashizan.py
practice_test/tashizan.py
# 記事(1)の通りです
def tashizan(a, b):
    """calculate (a + b)
    """
    return a + b
test/practice_test/test_tashizan.py
import unittest

from practice_test.tashizan import tashizan

class TestTashizan(unittest.TestCase):
    """test class of tashizan.py
    """

    def test_tashizan(self):
        """tashizanメソッドで足し算が行われるか確認する
        """
        value1 = 3
        value2 = 5
        expected = 8
        actual = tashizan(value1, value2)
        self.assertEqual(expected, actual)

if __name__ == '__main__':
    unittest.main()

カレントディレクトリからテストコードを実行したところ、以下のようにModuleNotFoundErrorが発生しました😱

$ python test/practice_test/test_tashizan.py 
Traceback (most recent call last):
  File "test/practice_test/test_tashizan.py", line 3, in <module>
    from practice_test.tashizan import tashizan
ModuleNotFoundError: No module named 'practice_test'

このエラーの解決に取り組んで知ったことをこの記事に記します。

原因

インタラクティブシェルで実験したところ、test/practice_testディレクトリの中でscriptsというモジュール(※)を探すも、見つからずにエラーになっているらしいと理解しました。
※ここで、モジュールとは、Pythonのファイル1またはPythonのファイルが置かれたディレクトリ2という認識です。

$ python  # 前提: 上記の.をカレントディレクトリとしている
>>> import practice_test  # importできる
>>> exit()
$ cd test/practice_test/
$ python
>>> import practice_test  # importできない
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'practice_test'

どうやらimportでは、実行されたPythonのファイルのあるディレクトリの中でモジュール(ディレクトリ)をimportしようとするようです。
(カレントディレクトリによらずに実行されたPythonのファイルの位置を起点にしているらしいとわかりました)

解決するまで

参考にしたのは 「Pythonのパッケージングのベストプラクティスについて考える2018」 という記事(2)です。

practice_testディレクトリ内のPythonのファイルをパッケージとしてインストールしたことで、test/practice_test/test_tashizan.pyの中でpractice_testをimportできるようになったと理解しています。

1.パッケージとしてインストールするために

setup.pyとsetup.cfgの2つのファイルを作ります。
記事(2)中の「5-4 setup.cfg に setup 関数の引数を書く」を参考にしています。

setup.py
from setuptools import setup
setup()
setup.cfg
[metadata]
name = test_first_time
version = 0.0.1
description = テストコードをディレクトリに分けて配置する構成の練習
keywords = unittest
classifiers =
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.7

[options]
install_requires =
  numpy

[options.extras_require]
dev = 
  flake8
  pytest

作成後のファイルの配置は以下のようになります。

.
├── practice_test
│   └── tashizan.py
├── setup.cfg  # 追加
├── setup.py  # 追加
└── test
    └── practice_test
        └── test_tashizan.py

2.パッケージとしてインストール

$ pip install -e .[dev]  # 前提: 上記の.をカレントディレクトリとしている
$ python
>>> import practice_test  # 相変わらずimportできる
>>> exit()
$ cd test/practice_test/
$ python
>>> import practice_test  # importできるようになった
>>> exit()

記事(2)によると、

  • setup.pyのあるディレクトリでpip install .を実行すると、パッケージがインストールされる
  • 開発者向けのパッケージのインストールの方法がpip install -e '.[dev]'3-eはPythonファイルへの変更が即座にパッケージの変更として反映される指定。devでsetup.cfgのextras_requiredevとして指定したパッケージがインストールされる)

とのことです。

エラーが解決された様子

テストが実行できるようになりました!

$ python test/practice_test/test_tashizan.py  # 上記の.をカレントディレクトリとしている
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
$ pytest  # extras_requireのdevの指定でpytestも入れたので実行可能
============================= test session starts ==============================
platform darwin -- Python 3.7.3, pytest-5.0.1, py-1.8.0, pluggy-0.12.0
rootdir: /Users/.../study/test_first_time
collected 1 item                                                               

test/practice_test/test_tashizan.py .                                    [100%]

=========================== 1 passed in 0.04 seconds ===========================

格闘の末に得たコードの全容は以下のリポジトリで確認できます:
https://github.com/ftnext/test_first_time/tree/902a8b12f677b7eefa5929ee65a185f876436e1e

pip install -e .前後の変化

sys.pathに追加がありました。

実行前(見やすくなるようにprintしたリストに改行を入れました)

>>> import sys
>>> print(sys.path)
['', '/Library/Frameworks/Python.framework/Versions/3.7/lib/python37.zip', 
'/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7', 
'/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/lib-dynload', 
'/Users/.../study/test_first_time/env/lib/python3.7/site-packages']

実行後:setup.pyを置いたディレクトリtest_first_time絶対パスが追加されています

>>> import sys
>>> print(sys.path)
['', '/Library/Frameworks/Python.framework/Versions/3.7/lib/python37.zip', 
'/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7', 
'/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/lib-dynload', 
'/Users/.../study/test_first_time/env/lib/python3.7/site-packages', 
'/Users/.../study/test_first_time']  # 追加

Pythonのドキュメント3中に

パッケージを import する際、 Python は sys.path 上のディレクトリを検索して、トップレベルのパッケージの入ったサブディレクトリを探します。

という記載があります。
'/Users/.../study/test_first_time'sys.pathに追加されたので、practice_testがimportできるようになったと考えています。

合わせて読んだ参考記事

(2)と合わせて、記事(3)「Pythonのパッケージ周りのベストプラクティスを理解する」も参考にしました。

(3)を読んだところ

  • setup.py はパッケージをインストールする仕組み
  • setup.pyが複雑になる問題の対応として、setup()のパラメータをsetup.cfg に書けるようにした

と理解しました。

pip install hogeで自動でPyPIからhogeをダウンロード&ZIPファイルを展開しsetup.pyを実行してくれます。((3)内の記載)

pip install .devでは、setup.pyを実行してインストールをしたと理解しています。

今後詰めていきたいところ

今回「Done is better than perfect」の精神でアウトプットしました。
理解が深まった際は、この記事を更新していきます。

以下に理解が甘い点や、確認したいドキュメントを記載します。


  1. 「(前略)Python では定義をファイルに書いておき、スクリプトの中やインタプリタの対話インスタンス上で使う方法があります。このファイルを モジュール (module) と呼びます。」(https://docs.python.org/ja/3/tutorial/modules.html より) 

  2. ディレクトリがパッケージであり、パッケージはモジュールの一形態と認識しています。「パッケージはファイルシステムのディレクトリ、モジュールはディレクトリにあるファイルと考えることができますが、(後略。これは比喩とも書かれている)」「(前略)他の言い方をすると、パッケージは単なる特別な種類のモジュールであると言えます。」(いずれも https://docs.python.org/ja/3/reference/import.html#packages より) 

  3. pip install -e .[dev] でも動いたのでクォートは必須ではなさそうです。 

  4. https://setuptools.readthedocs.io/en/latest/setuptools.html#develop-deploy-the-project-source-in-development-mode 

14
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
12