1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Pythonのunittestでmock_openを使った時、openを複数回呼び出すコードは、assert_has_callsではなくcall_args_listで検証すると学びました

Last updated at Posted at 2021-01-24

「Pythonのunittestを使ったコードにおいて、関数呼び出しの引数は、モックのassert_xxx系のメソッドで常に検証できる」という思い込みが粉砕されたので、アウトプットします。
だいぶハマったので再発防止の目的です。

モックのassert_xxx系のメソッドとは、公式ドキュメントの以下にあるメソッドを指しています(例:assert_called_once_with)。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock

環境

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_listassertEqualするというのがポイントでした。

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回呼び出されるので、モックmassert_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_call3

モックが特定の引数で呼び出されたことがあるのをアサート

>>>  AssertionErrorになった対話モードの続き
>>> m.assert_any_call("foo", "w")
>>> m.assert_any_call("foo")
  1. 具体的にはこのスクリプトにテストを書こうとしました(importできるように、まず関数に変換しています)

  2. 「ファイル名の指定が同じだから?」と考え、書き込むファイル名をbarに変えてみましたが、AssertionErrorは解決しませんでした。また、patchwithを1つにまとめてみましたが、解決しませんでした。

  3. 似た名前のメソッドassert_called_withは「last call」(最後の呼び出し)を検証します(ref: ドキュメント

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?