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

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

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Pythonのunittest.mockでめっちゃハマりました。なんとか突破<br><br>私の知っている範囲の some_mock.assert_xxx メソッド呼び出しでは、アサートできない引数のパターンが存在していました。<br>mock. call_args_list を検証して解決。<br>全部assert_xxxで書けるという思い込みが粉々にされました</p>&mdash; nikkie (@ftnext) <a href="https://twitter.com/ftnext/status/1352919825568260096?ref_src=twsrc%5Etfw">January 23, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

## 環境

macOS
Python 3.8.6

- pytest 6.0.1（テストランナーとして使用。**テストコードは`unittest`を使っています**）

## テストコードを書きたいコード（イメージ）

同名のファイルを複数回`open`するコード[^1]です。
ファイルの読み書き無しでテストが実行できるよう、`open`にモックを当てて、モックを呼び出す引数を検証しようと考えました。

```python
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)
```

[^1]: 具体的には[このスクリプト](https://github.com/codeforjapan/mynumbercard_statistics/blob/76d5cbd5b4cbe08aa94caf4998cffd9759c7b51a/mynumbercard_data/download.py#L57)にテストを書こうとしました（importできるように、まず関数に変換しています）

## mock_openについて少し

テストの実装には、`unittest.mock`にある`mock_open`を使います。
https://docs.python.org/ja/3/library/unittest.mock.html#mock-open

>open() の利用を置き換えるための mock を作るヘルパー関数。 open() を直接呼んだりコンテキストマネージャーとして利用する場合に使うことができます。

サンプルコード（`mock_open`のドキュメントより）

```python
>>> 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] 書くべきと分かったテストコード

```python
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

<details>
<summary>補足：`mock_open`の`read_data`引数について</summary>
<div>

ドキュメントに「mock が呼ばれるたびに read_data は先頭に巻き戻されます」とあるのを見て、「書き込みモードでも影響はないだろう」と判断しました。
読み取り／書き込みのモードで分けることはせずに、`open`に一括でモックを当てています。

</div>
</details>

## 当初書いたコード

`open`は2回呼び出されるので、モック`m`の`assert_has_calls`で検証しようとしました。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock.assert_has_calls
>呼び出しでは mock_calls のリストがチェックされます。

```python
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のバグなのか仕様なのかまでは踏み込めていません）

```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)]
```

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

## `mock_open`でのみ発生（`Mock`については発生しない）

モックについて同じメソッドを複数回呼び出したときは、`assert_has_calls`で検証できました（`MagicMock`で検証した例）。

```python
>>> 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]

https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock.assert_any_call
>モックが特定の引数で呼び出されたことがあるのをアサート

```python
>>> ＃ AssertionErrorになった対話モードの続き
>>> m.assert_any_call("foo", "w")
>>> m.assert_any_call("foo")
```

[^3]: 似た名前のメソッド`assert_called_with`は「last call」（**最後の呼び出し**）を検証します（ref: [ドキュメント](https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_with)）