Edited at
LIFULLDay 1

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

LIFULL Advent Calendar 1日目の記事です。今年もよろしくお願いします。

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

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

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

$ 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


とりあえず書いてみよう

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


src.script.py

def greet(name):

"""挨拶を返す。

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

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


pytest用のコードでは、「先程のコードをインポートして、結果をassertする」ことでチェックできます。RubyのRspec等と比べると、そのまま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文で値をチェックして、その結果をエラー時に分かりやすく出力してくれるもの」だと思って貰えれば大丈夫だと思います。エラーのチェック等、便利な機能は多少あります(後述)。

$ 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デコレータを使って共通化しましょう。


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


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

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


エラー時のテスト

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


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}!"


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


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でチェックします。


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!")


$ 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だったので、ちゃんとバリデーションしているようです。ただし、実際に「たかし」さんをテストケースに加えると、バリデーションできていないことがわかります。

$ 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ひらがなやカタカナ、漢字もTrueとして返しており、想定していた「アルファベットのみで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を使ってます。

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


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のもので、patch.objectmockライブラリで提供されているものです。pytest-mockを使うことで、第一引数のmockerに集約されて使い勝手よくなっていると思うのですが、いかがでしょうか?


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クラスの機能を調べてみてください。


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さんが、以前このようなことを仰ってました。

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


参考書籍・記事

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

pytest公式ドキュメント

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

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

テスト駆動Python

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

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