概要
Python で遊び始めた日から、8年が経過しているな。最近のブームは、作った Python 関数に対して、ユニットテストをきちんと作ることだ。それによってぼくの関数は信用に足るものとなる。ユニットテストについては、6年前にも挑戦したことがあったのだけど……
当時は nose を使っていたんだな。でもまあ、ぼくの趣味レベルでは、標準モジュールの unittest で十分かな。
今回は、モックを使ったユニットテストについて、実用的なサンプルをまとめるぜ。
テスト対象の関数 (my_module)
import logging
logger = logging.getLogger(__name__)
def _bar():
raise NotImplementedError()
def foo(i: int) -> list[int | None]:
"""ユニットテストで遊ぶためのターゲット関数。
与えた数のぶんだけ _bar を呼び出して、その結果を list[int | None] にして返す。
_bar が Exception を投げたとき None が入ります。
Args:
i (int): _bar を呼び出す回数。
"""
results: list[int | None] = []
for _ in range(i):
try:
# _bar の結果を追加。
results.append(_bar())
except Exception:
# なんかエラー出たら、ログ出して None を追加。
logger.exception("例外発生!! ロギングしつつ処理は続行する。")
results.append(None)
return results
今回はモックで遊びたいので、モックが大活躍できるような foo
を作ったぞ。 foo
の中で呼ばれている関数 _bar
は未実装なので、そのまま foo
を呼び出すとゼッタイエラーになってしまう。 _bar
をモックとすげ替えるテストを作ろうぜ。
ユニットテスト
レベル1
_bar
が 15
を返すようにしようぜ。
from unittest import TestCase
from unittest.mock import patch, MagicMock
class TestFoo(TestCase):
# _bar が 15 を返すようにしたい。 -> [15] が返ってくることを確認。
@patch('my_module._bar', MagicMock(return_value=15, autospec=True))
def test_foo(self):
actual = foo(1)
self.assertEqual(actual, [15])
レベル1だ。見やすいね。デコレータ部分を見るだけで、
_bar
をパッチワークにしてるのね、MagicMock
とすげ替えているのね、15
が返るようにしているのね、autospec
で、ホンモノの_bar
のシグネチャ (引数の数とか) を継承してくれてるのね、
……というのが分かる。
レベル2
まあこれ↑でもいいんだけれど、デコレータの中で MagicMock
を定義する形式だと、この先、ちっと複雑なモック関数とすげ替えようと思ったとき限界がくる。デコレータの中で複雑なことをするのは不可能だから。
だからすげ替える関数を、 def
形式で作って、それを MagicMock
の side_effect
として定義しよう。
from unittest import TestCase
from unittest.mock import patch, MagicMock
class TestFoo(TestCase):
# 書き方を side_effect で統一したい。
@patch('my_module._bar', autospec=True)
def test_foo(self, mock_bar: MagicMock):
def custom_bar(*args, **kwargs):
return 15
mock_bar.side_effect = custom_bar
actual = foo(1)
self.assertEqual(actual, [15])
これはぼくには結構見やすいな。
_bar
をパッチワークにするのね、- すげ替える
MagicMock
をテスト関数の中で受け取れるのね、 MagicMock
のside_effect
として、どんな関数へ置き換えるのか定義できるのね、
というのが見やすい。
ところで、すげ替える先の処理の名前が “side effect” なのって違和感があるよな。どちらかといえば “main effect” じゃないか? って感じがしちゃうもんね。これはだな、
- この side effect は
MagicMock
の副作用である、 MagicMock
の主目的は、 “本来であれば (_bar
などが) 何か動きをするところを、何もしない、何も返さないようにすげ替える” ことである、- それをモックの主目的であると考えると、なにか具体的な値を返すことは、
MagicMock
の副作用である、
……というように考えると、納得できる。
レベル3
レベル2で書いた、 “ちっと複雑なモック関数とすげ替えよう” としたパターンがこちらだ。
_bar
が複数回呼ばれて、その度に違う値を返すようにしたいとか、- そのうちの何度かは
Exception
を投げるようにしたいとか、
そういう設定でモックの side effect を作りたい場合はこうなる。
from unittest import TestCase
from unittest.mock import call, patch, MagicMock
class TestFoo(TestCase):
# _bar が 15, Exception, 8, Exception, 4 を順番に返すようにしたい。
# side_effect が猛威を振るう。
@patch('my_module._bar', autospec=True)
@patch('my_module.logger.exception', autospec=True)
def test_foo(self, mock_logger: MagicMock, mock_bar: MagicMock):
def custom_bar(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return 15
elif call_count == 3:
return 8
elif call_count == 5:
return 4
else:
raise Exception()
call_count = 0
mock_bar.side_effect = custom_bar
actual = foo(5)
self.assertEqual(actual, [15, None, 8, None, 4])
expected_calls = [
call("例外発生!! ロギングしつつ処理は続行する。"),
call("例外発生!! ロギングしつつ処理は続行する。")
]
mock_logger.assert_has_calls(expected_calls)
ついでにロガーが2度呼ばれていることも確認しているぜ。そう、 MagicMock
は、コールされたことを検知することもできる。