pytestとpytest-mockでPythonのユニットテストを始めよう

[LIFULL Advent Calendar 1日目](https://qiita.com/advent-calendar/2018/lifull)の記事です。今年もよろしくお願いします。

世間一般の流れと同じく、[株式会社LIFULL](https://lifull.com/)でも機械学習サービスを中心にPythonを採用することが多くなってきました。ただ、どうしても「リリースしてみないとそもそも機械学習モデルが妥当なのか分からない」ため、本当に最低限のテストだけで運用を始めることも多く、そのせいで分析寄りのメンバーに必要以上に属人化して負担をかけてしまっているように思います。

今回は後輩や同僚のエンジニア or データサイエンティスト向けに、「pytestを使ってメンテナンス性よくPythonを使ってこうぜ！」って布教するための記事です。

今回の記事に使ったライブラリのバージョンはこちらです。もしバージョンが違う場合、挙動が違う場合があるので必要に応じて調べてください。

```bash
$ python -V
Python 3.6.5
$ pip freeze | grep pytest
pytest==4.0.1
pytest-mock==1.10.0
```

今回使ったコードはGithubにもあるので、参考にしてみてください。pipenvを利用しています。

https://github.com/takeshi0406/pytest-sample

## とりあえず書いてみよう


「いかにもテストコードのために書きました」って例になってしまいましたが、「名前を受け取ると挨拶を返す関数」を作りました。

```python:src.script.py
def greet(name):
    """挨拶を返す。

    Args:
        name [str]: 名前
    Returns:
        str: 英語の挨拶

    """
    return f"Hello, {name}!"
```

pytest用のコードでは、「先程のコードをインポートして、結果をassertする」ことでチェックできます。RubyのRspec等と比べると、そのままPythonのコードとして自然に読めますね。

```python:test.test_greet.py
from src import script


def test_greet():
    assert script.greet("Guido") == "Hello, Guido!"
    assert script.greet("Django") == "Hello, Django!"
```

そして、pytestコマンドで実行します。引数のファイルを指定していますが、指定しない場合はtest\_\*\*\*.pyや\*\*\*\_test.pyといったファイルの中のtest\_\*\*\*, \*\*\*\_testといった関数（やクラス）が呼ばれます。

一旦、「assert文で値をチェックして、その結果をエラー時に分かりやすく出力してくれるもの」だと思って貰えれば大丈夫だと思います。エラーのチェック等、便利な機能は多少あります（後述）。

```bash
$ pytest test/test_greet.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.6.5, pytest-4.0.1, py-1.7.0, pluggy-0.8.0
rootdir: /Users/takeshi/Desktop/projects/pytest-sample, inifile:
plugins: mock-1.10.0
collected 1 item

test/test_greet.py .                                                                                                                            [100%]

============================================================== 1 passed in 0.01 seconds ===============================================================
```

さらにテスト対象の名前を増やしたい場合、assert文をコピペしていくのは面倒くさいですよね。[```pytest.mark.parametrize```](https://docs.pytest.org/en/latest/example/parametrize.html)の[デコレータ](https://docs.python.jp/3/glossary.html#term-decorator)を使って共通化しましょう。

```test/test_script.py
import pytest
from src import script


@pytest.mark.parametrize(("name" ,"expected"), [
    ("Jason", "Hello, Jason!"),
    ("Django", "Hello, Django!")
])
def test_greet_ver2(name, expected):
    assert script.greet(name) == expected
```

この例だと微妙ですが、自分たちのチームの場合[広告費最適化](https://www.slideshare.net/next_developer/lifull-104048095)のように、「不動産マーケットや広告媒体ごとに分岐があってそれらの組み合わせをチェックしたい」ケースは多いと思います。

意外と、条件分岐を入り組んだ関数やメソッドに対して、[ディシジョンテーブル](https://softest.jp/?p=72)などを使ってテストコードを書くだけでも意外と仕様漏れが見つかったりします。リファクタリングする際も、「正常系の入力出力を変えないようにして中身を整理する」ことができるのでかなり楽になります。

## エラー時のテスト

「英語の名前以外はバリデーションして受け付けない関数」を作ってみましょう。

```python:src/script.py
def greet_if_alphabet_name(name):
    """英語の名前のときに挨拶を返す。

    Args:
        name [str]: 名前
    Returns:
        str: 英語の挨拶
    Raises:
        ValueError: アルファベットの名前でなかった場合

    """
    if not name.isalpha():
        raise ValueError("Sorry! This method is English Only!")
    return f"Hello, {name}!"
```

英語の名前はもちろん受け付るので、こちらのテストは通ります。

```python:test/test_greet_if_alphabet_name.py
@pytest.mark.parametrize(("name", "expected"), [
    ("Jason", "Hello, Jason!"),
    ("Django", "Hello, Django!"),
])
def test_response(name, expected):
    assert script.greet_if_alphabet_name(name) == expected
```

適当な入力を与えて、[```pytest.raises```](https://docs.pytest.org/en/latest/assert.html)でチェックします。

```python:test/test_greet_if_alphabet_name.py
@pytest.mark.parametrize("name", [
    "luhae;asev[asveoh", "ouh;ouaea", "vaoue;aoaあああ"
])
def test_error(name):
    with pytest.raises(ValueError) as e:
        script.greet_if_alphabet_name(name)

    # エラーメッセージを調べるには、エラーオブジェクトを調べるしかないようです。
    # この場合、e.valueがValueErrorのインスタンス
    assert str(e.value).startswith("Sorry! This method is English Only!")
```

```bash
$ pytest test/test_greet_if_alphabet_name.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.6.5, pytest-4.0.1, py-1.7.0, pluggy-0.8.0
rootdir: /Users/takeshi/Desktop/projects/pytest-sample, inifile:
plugins: mock-1.10.0
collected 5 items

test/test_greet_if_alphabet_name.py .....                                                                                                       [100%]

============================================================== 5 passed in 0.02 seconds ===============================================================
```

これはテストOKだったので、ちゃんとバリデーションしているようです。ただし、実際に「たかし」さんをテストケースに加えると、バリデーションできていないことがわかります。

```bash
$ pytest test/test_greet_if_alphabet_name.py
================================================================= test session starts =================================================================
platform darwin -- Python 3.6.5, pytest-4.0.1, py-1.7.0, pluggy-0.8.0
rootdir: /Users/takeshi/Desktop/projects/pytest-sample, inifile:
plugins: mock-1.10.0
collected 6 items

test/test_greet_if_alphabet_name.py .....F                                                                                                      [100%]

====================================================================== FAILURES =======================================================================
___________________________________________________________ test_error[\u305f\u304b\u3057] ____________________________________________________________

name = 'たかし'

    @pytest.mark.parametrize("name", [
        "luhae;asev[asveoh", "ouh;ouaea", "vaoue;aoaあああ", "たかし"
    ])
    def test_error(name):
        with pytest.raises(ValueError) as e:
>           script.greet_if_alphabet_name(name)
E           Failed: DID NOT RAISE <class 'ValueError'>

test/test_greet_if_alphabet_name.py:18: Failed
========================================================= 1 failed, 5 passed in 0.08 seconds ==========================================================
```

実はPythonの[```str.isalpha```](https://docs.python.org/ja/3/library/stdtypes.html?highlight=isalpha#str.isalpha)は[ひらがなやカタカナ、漢字もTrueとして返して](https://note.nkmk.me/python-str-num-determine/)おり、想定していた「アルファベットのみでTrue」という挙動ではありませんでした。

というわけで、「アルファベット以外が含まれていたらエラーを出す」ように正規表現でバリデーションするよう変更しました。

```src/script.py
import re


def greet_if_alphabet_name(name):
    """英語の名前のときに挨拶を返す。

    Args:
        name [str]: 名前
    Returns:
        str: 英語の挨拶
    Raises:
        ValueError: アルファベットの名前でなかった場合

    """
    if re.search(r"[^a-zA-Z]", name):
        raise ValueError("Sorry! This method is English Only!")
    return f"Hello, {name}!"
```

こちらは先程のテストを通ります。

## モックを作る

私はpytestでモックライブラリを使うための薄いラッパーライブラリである[```pytest-mock```](https://pypi.org/project/pytest-mock/)を使ってます。

次のような、「今まで挨拶した人のログを参照して値を変える関数」をテストしてみましょう。

```python:src/script.py
def greet_and_remember(name):
    """ログを参照して挨拶を返す。

    Args:
        name [str]: 名前
    Returns:
        str: 日本語の挨拶

    """
    log = LogFile("./logs/names.txt")
    if log.inclides(name):
        return f"また会いましたね、{name}さん"
    else:
        log.append(name)
        return f"はじめまして、{name}さん"
```


実は```mocker.Mock```は[標準ライブラリのunittest](https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock)のもので、```patch.object```は[mockライブラリで提供されているもの](http://www.voidspace.org.uk/python/mock/patch.html#patch-object)です。pytest-mockを使うことで、第一引数のmockerに集約されて使い勝手よくなっていると思うのですが、いかがでしょうか？


```python:test/test_greet_and_remember.py
import pytest
from src import script


@pytest.mark.parametrize(("exists", "expected"), [
    (True, "また会いましたね、Jasonさん"),
    (False, "はじめまして、Jasonさん")
])
def test_return_value(mocker, exists, expected):
    # クラスのモックをセットする
    # 冗長な気がするので、もっと良い書き方があるのかもしれません
    insmock = mocker.Mock()
    insmock.includes.return_value = exists # メソッドの返却値はreturn_valueで指定
    mocker.patch.object(script, "LogFile", mocker.Mock(return_value=insmock))

    # 期待値のチェック
    assert script.greet_and_remember("Jason") == expected
```

また、Mockオブジェクトは、メソッドが呼ばれたかや引数をチェックすることもできます。詳しくは[Mockクラスの機能](https://docs.python.jp/3/library/unittest.mock.html#the-mock-class)を調べてみてください。

```python:test/test_greet_and_remember.py
def test_if_exists(mocker):
    insmock = mocker.Mock()
    insmock.includes.return_value = True
    mocker.patch.object(script, "LogFile", mocker.Mock(return_value=insmock))

    assert script.greet_and_remember("Jason") == "また会いましたね、Jasonさん"
    insmock.append.assert_not_called()


def test_if_not_exists(mocker):
    insmock = mocker.Mock()
    insmock.includes.return_value = False
    mocker.patch.object(script, "LogFile", mocker.Mock(return_value=insmock))

    assert script.greet_and_remember("Jason") == "はじめまして、Jasonさん"
    insmock.append.assert_called_once()
```

## テストコードを書くメリットや注意点

テストコードがあると、後から入った開発者にとっては、継続的にメンテナンスされていることが保証されているドキュメントが手に入ることになるので助かります。もちろんテストコードさえ書けば充分ではありませんが、少なくとも「どんな入力・出力が想定の範囲内なのか」はチェックできると思います。

TDDで有名な[@t_wada](https://twitter.com/t_wada)さんが、以前このようなことを仰ってました。

<blockquote class="twitter-tweet" data-lang="en"><p lang="ja" dir="ltr">コードには How<br>テストコードには What<br>コミットログには Why<br>コードコメントには Why not<br><br>を書こうという話をした</p>&mdash; Takuto Wada (@t_wada) <a href="https://twitter.com/t_wada/status/904916106153828352?ref_src=twsrc%5Etfw">September 5, 2017</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

「テストさえ書けば品質高くなる」みたいに、プログラマーが慢心しがちなアンチパターンはDevelopersIOの[ユニットテストにまつわる10の勘違い](https://dev.classmethod.jp/testing/10_errors_about_unit_testing/)という記事で紹介されているので読んでみてください。

## 参考書籍・記事

pytestやモックライブラリには、その他「テスト時に一時的にディレクトリを作る」「前処理を共通化する」等の機能があります。

**pytest公式ドキュメント**

https://docs.pytest.org/en/latest/

具体的に「〇〇の機能の引数でこれも指定できないかな」みたいなときには公式ドキュメントを追いましょう。

**テスト駆動Python**

https://www.amazon.co.jp/dp/B07F65PFZN/

最近出たpytestの本です。私も持っているので一緒に勉強しましょう。ただ、元々ユニットテストの知識が無いと、ちょっと使いづらい内容に思えました。