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

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

## 解決策

### アップデート版 解決策 (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 unittest`は`python -m unittest discover -s . -p test*.py`と同じで
カレントディレクトリ以下の`test*.py`というファイル名のパターンに該当するテストを実行しているそうです。
詳しく知りたい方はドキュメントの「[テストディスカバリ](https://docs.python.org/ja/3/library/unittest.html#test-discovery)」をご参照ください。

以上は、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の使い方メモ](https://qiita.com/aomidro/items/3e3449fde924893f18ca)」の写経に取り組んでいました。
元の記事(1)はPython2系で動かしていますが、手元ではPython3系で動かしました。

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

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

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

```
.  # カレントディレクトリ
├── practice_test  # テスト対象のPythonのファイルを置く
│   └── tashizan.py
└── test  # テストを置く
    └── practice_test  # practice_test内のPythonファイルのテストを置く
        └── test_tashizan.py
```

```python:practice_test/tashizan.py
# 記事(1)の通りです
def tashizan(a, b):
    """calculate (a + b)
    """
    return a + b
```

```python: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が発生しました😱

```shell
$ 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]という認識です。

```shell
$ 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のファイルの位置を起点にしているらしいとわかりました）

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

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

## 解決するまで

参考にしたのは 「[Pythonのパッケージングのベストプラクティスについて考える2018](https://techblog.asahi-net.co.jp/entry/2018/06/15/162951)」 という記事(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 関数の引数を書く」を参考にしています。

```python:setup.py
from setuptools import setup
setup()
```

```cfg: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.パッケージとしてインストール

```shell
$ 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_require`に`dev`として指定したパッケージがインストールされる）

とのことです。

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

### エラーが解決された様子

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

```shell
$ 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したリストに改行を入れました）

```python
>>> 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`の**絶対パスが追加**されています

```python
>>> 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できるようになったと考えています。

[^3]: https://docs.python.org/ja/3/tutorial/modules.html#packages

## 合わせて読んだ参考記事

(2)と合わせて、記事(3)「[Pythonのパッケージ周りのベストプラクティスを理解する](https://www.m3tech.blog/entry/python-packaging)」も参考にしました。

(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」の精神でアウトプットしました。
理解が深まった際は、この記事を更新していきます。

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

- `pip install -e .`は`python setup.py develop`[^4]と同じ？（pipコマンドとsetup.pyの対応関係を知りたい）
- setup.cfgの書き方：https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files

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