「Pythonのunittest
を使ったコードにおいて、関数呼び出しの引数は、モックのassert_xxx系のメソッドで常に検証できる」という思い込みが粉砕されたので、アウトプットします。
だいぶハマったので再発防止の目的です。
モックのassert_xxx系のメソッドとは、公式ドキュメントの以下にあるメソッドを指しています(例:assert_called_once_with
)。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock
Pythonのunittest.mockでめっちゃハマりました。なんとか突破
— nikkie (@ftnext) January 23, 2021
私の知っている範囲の some_mock.assert_xxx メソッド呼び出しでは、アサートできない引数のパターンが存在していました。
mock. call_args_list を検証して解決。
全部assert_xxxで書けるという思い込みが粉々にされました
環境
macOS
Python 3.8.6
- pytest 6.0.1(テストランナーとして使用。テストコードは
unittest
を使っています)
テストコードを書きたいコード(イメージ)
同名のファイルを複数回open
するコード1です。
ファイルの読み書き無しでテストが実行できるよう、open
にモックを当てて、モックを呼び出す引数を検証しようと考えました。
DATA_FILE = "./data/loaded_files.json"
with open(DATA_FILE) as f:
loaded = json.load(f)
# 処理(省略)
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(loaded, f, indent=2, ensure_ascii=False)
mock_openについて少し
テストの実装には、unittest.mock
にあるmock_open
を使います。
https://docs.python.org/ja/3/library/unittest.mock.html#mock-open
open() の利用を置き換えるための mock を作るヘルパー関数。 open() を直接呼んだりコンテキストマネージャーとして利用する場合に使うことができます。
サンプルコード(mock_open
のドキュメントより)
>>> from unittest.mock import mock_open, patch
>>> m = mock_open()
>>> with patch("__main__.open", m): # 対話モードで実行しているので __main__ を指定
... with open("foo", "w") as h:
... h.write("some stuff")
...
>>> m.assert_called_once_with("foo", "w")
open
を呼び出す際の引数を検証できました。
[TL; DR] 書くべきと分かったテストコード
from unittest.mock import call, mock_open, patch
# 以下はTestCaseのメソッドの中に書いています
m = mock_open(read_data='{"spam": 42}')
with patch("builtins.open", m):
sut.main() # テスト対象のメソッド実行
self.assertEqual(
m.call_args_list,
[
call("./data/loaded_files.json"),
call("./data/loaded_files.json", "w", encoding="utf-8"),
],
)
💡 m.call_args_list
をassertEqual
するというのがポイントでした。
call_args_list
は「モックの呼び出しを順に記録したリスト」(要素はcall
オブジェクト)です。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock.call_args_list
補足:`mock_open`の`read_data`引数について
ドキュメントに「mock が呼ばれるたびに read_data は先頭に巻き戻されます」とあるのを見て、「書き込みモードでも影響はないだろう」と判断しました。
読み取り/書き込みのモードで分けることはせずに、open
に一括でモックを当てています。
当初書いたコード
open
は2回呼び出されるので、モックm
のassert_has_calls
で検証しようとしました。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock.assert_has_calls
呼び出しでは mock_calls のリストがチェックされます。
from unittest.mock import mock_open, patch
# 以下はTestCaseのメソッドの中に書いています
m = mock_open(read_data='{"spam": 42}')
with patch("builtins.open", m):
sut.main() # テスト対象のメソッド実行
m.assert_has_calls(
[
call("./data/loaded_files.json"),
call("./data/loaded_files.json", "w", encoding="utf-8"),
]
)
通らないAssertion
E AssertionError: Calls not found.
E Expected: [call('./data/loaded_files.json'),
E call('./data/loaded_files.json', 'w', encoding='utf-8')]
E Actual: [call('./data/loaded_files.json'),
E call().__enter__(),
E call().read(),
E call().__exit__(None, None, None),
E call('./data/loaded_files.json', 'w', encoding='utf-8'),
E call().__enter__(),
E call().__exit__(None, None, None)]
mock_open
で当てたモックが複数回呼び出された時、どうやらassert_has_calls
では検証できないようです2。
以下に示すように1回だけ呼び出した場合はassert_has_calls
で検証できるので、今ひとつ解せません。
(これがPythonのバグなのか仕様なのかまでは踏み込めていません)
>>> from unittest.mock import call, mock_open, patch
>>> m = mock_open()
>>> with patch("__main__.open", m):
... with open("foo") as h1:
... h1.read()
...
''
>>> m.mock_calls
[call('foo'),
call().__enter__(),
call().read(),
call().__exit__(None, None, None)]
>>> m.assert_has_calls([call("foo")])
>>> # 書き込みモードでも同一のファイルをopenする
>>> with patch("__main__.open", m):
... with open("foo", "w") as h2:
... h2.write("egg")
...
>>> m.mock_calls
[call('foo'),
call().__enter__(),
call().read(),
call().__exit__(None, None, None),
call('foo', 'w'),
call().__enter__(),
call().write('egg'),
call().__exit__(None, None, None)]
>>> # mock_callsに call('foo') があるにもかかわらずAssertionError
>>> m.assert_has_calls([call("foo"), call("foo", "w")])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/unittest/mock.py", line 950, in assert_has_calls
raise AssertionError(
AssertionError: Calls not found.
Expected: [call('foo'), call('foo', 'w')]
Actual: [call('foo'),
call().__enter__(),
call().read(),
call().__exit__(None, None, None),
call('foo', 'w'),
call().__enter__(),
call().write('egg'),
call().__exit__(None, None, None)]
mock_open
でのみ発生(Mock
については発生しない)
モックについて同じメソッドを複数回呼び出したときは、assert_has_calls
で検証できました(MagicMock
で検証した例)。
>>> from unittest.mock import call, MagicMock
>>> m = MagicMock()
>>> m.awesome_method("foo")
<MagicMock name='mock.awesome_method()' id='4387550112'>
>>> m.awesome_method("foo", 42)
<MagicMock name='mock.awesome_method()' id='4387550112'>
>>> m.awesome_method.mock_calls
[call('foo'), call('foo', 42)]
>>> m.awesome_method.assert_has_calls([call("foo"), call("foo", 42)])
別の方法:assert_any_call
3
モックが特定の引数で呼び出されたことがあるのをアサート
>>> # AssertionErrorになった対話モードの続き
>>> m.assert_any_call("foo", "w")
>>> m.assert_any_call("foo")