mock_openを使用したファイル処理のユニットテスト

# mock_openについて
mock_openはファイル処理のユニットテスト用に`open()`を置き換えるヘルパー関数です。
`open()`にパッチを適用してmock_openと置き換えることで、ユニットテスト実行環境のファイルシステムを意識せずに、ファイル処理のユニットテストが作成できます。

# mock_openの使用方法

## mock_openが使用可能なユニットテストの実行環境

mock_openはpythonの標準ライブラリ（unittest.mockモジュール）に含まれていますので、pythonが動作する環境であればmock_openが使用できます。

本記事では、pytestパッケージ、およびpytest-mockパッケージをインストールした環境向けにユニットテストの例を記載していますので、必要に応じて両パッケージをインストールしてください。

```bash
# pytestパッケージのインストール
pip install pytest
# pytest-mockパッケージのインストール
pip install pytest-mock
```

## mock_openのパッチ適用方法
いずれの適用方法でもpythonの組み込み関数である、`open()`にmock_openを適用します。
そのため、パッチの対象は`builtins.open`となります。

### コンテキストマネージャーとしてmock_openを適用
with文を使用してmock_openを適用します。
パッチの適用範囲はwith文のブロック内に限定されます。

```python:tests/test_foo.py
from unittest.mock import patch, mock_open

from sample.foo import Foo


def test_save_txt():
    open_mock = mock_open()
    with patch("builtins.open", open_mock):
        # パッチの適用範囲内でsave_txtメソッドを実行
        foo = Foo()
        foo.save_txt("path/to/file")

    # open関数が1回呼び出されていることをテスト
    open_mock.assert_called_once()
```

mockオブジェクトはwith文のas節で受け取ることも可能です。

```python
    with patch("builtins.open", mock_open()) as open_mock:
        ...
```

### 関数デコレータを使用してmock_openを適用
`patch()`デコレータを使用してmock_openを適用します。
パッチの適用範囲は関数内すべてとなります。

```python:tests/test_foo.py
from unittest.mock import patch, mock_open

from sample.foo import Foo


# Mockオブジェクトはテストメソッドの第一引数で渡される
@patch("builtins.open", new_callable=mock_open)
def test_save_txt(open_mock):
    # パッチの適用範囲内でsave_txtメソッドを実行
    foo = Foo()
    foo.save_txt("path/to/file")

    # open関数が1回呼び出されていることをテスト
    open_mock.assert_called_once()
```

### mocker fixtureを使用してmock_openを適用
pytest-mockのmocker fixtureを使用してmock_openを適用します。
パッチの適用範囲は`mocker.patch()`呼び出し以降となります。

```python:tests/test_foo.py
from sample.foo import Foo


# テストメソッドの第一引数: mockerは、pytest-mockのmocker fixture
def test_save_txt(mocker):
    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    # パッチの適用範囲内でsave_txtメソッドを実行
    foo = Foo()
    foo.save_txt("path/to/file")

    # open関数が1回呼び出されていることをテスト
    open_mock.assert_called_once()
```

# mock_openを使用したユニットテストの作成
mock_openを使用したユニットテストの作成例を記載します。
pytest、pytest-mockがインストールされているテスト実行環境向けに記載していますので、pytestパッケージ、pytest-mockパッケージがインストールされていない環境では、前述のパッチ適用方法を参考に、適宜読み替えてください。

## テスト対象のソースコード

```python:sample/foo.py
import json


class Foo:
    def __init__(self):
        self._value = 0

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        self._value = value

    def save_json(self, path):
        with open(path, mode="w", encoding="utf-8") as file:
            json.dump({"value": self._value}, file, indent=2)

    def load_json(self, path):
        with open(path, mode="r", encoding="utf-8") as file:
            self._value = json.load(file)["value"]

    def save_txt(self, path):
        with open(path, mode="w", encoding="utf-8") as file:
            file.write(str(self._value))

    def load_txt(self, path):
        with open(path, mode="r", encoding="utf-8") as file:
            self._value = int(file.read())
```

## ファイルオープン処理のテスト

### オープンされたファイルのテスト
mock_open呼び出し時の引数から、オープンされたファイルのパス、モード指定等、`open()`呼び出し時の引数がテストできます。

```python:tests/test_foo.py
from pathlib import Path

from sample.foo import Foo


def test_save_txt(mocker):
    path = Path("path/to/file")

    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    foo = Foo()
    foo.save_txt(path)

    # open()呼び出し時の引数から、オープンされたファイルのパスをテスト
    open_mock.assert_called_once_with(path, mode="w", encoding="utf-8")
```

### ファイルオープン処理中に発生する例外のテスト
mock_openの`side_effect`に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

```python:tests/test_foo.py
from sample.foo import Foo


def test_save_txt(mocker):
    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    # open_mock.side_effectに例外オブジェクトを設定
    open_mock.side_effect = FileNotFoundError

    # FileNotFoundErrorが送出されることをテスト
    with pytest.raises(FileNotFoundError):
        foo = Foo()
        foo.save_txt("path/to/file")
```

## ファイル書き込み処理のテスト

### ファイルに書き込んだデータのテスト
mock_openが返却するhandleモックのwrite属性に適用されているモック（以降、writeモック）を使用することで、ファイルに書き込まれた内容がテストできます。

```python:tests/test_foo.py
from sample.foo import Foo


def test_save_txt(mocker):
    value = 123

    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    foo = Foo()
    foo.value = value
    foo.save_txt("path/to/file")

    # ファイルの書き込み内容 = write()の引数をテスト
    handle = open_mock()
    handle.write.assert_called_once_with(str(value))
```

ループ等で`write()`が複数回呼び出される場合は、`write.call_args_list`を期待値と比較することで、書き込み内容がテストできます。

```python
    # 書き込み内容 = write()呼び出し時の引数をテスト
    handle = open_mock()
    assert handle.write.call_args_list == [
        call("1回目の書き込み内容"),
        call("2回目の書き込み内容"),
        ...
        call("N回目の書き込み内容"),
    ]
```

`Mock.assert_has_calls()`を使用してもテストできますが、期待値以外の`write()`の呼び出しがあった場合でもテストが成功するので、必要であれば`write.call_count`を参照して`write()`の呼び出し回数もテストしてください。

```python
    # 期待値となるwrite関数呼び出し時の引数リスト
    expect_calls = [
        call("1回目の書き込み内容"),
        call("2回目の書き込み内容"),
        ...
        call("N回目の書き込み内容"),
    ]

    handle = open_mock()
    # ファイル書き込み内容 = write()呼び出し時の引数をテスト
    # "1回目の書き込み内容"の前、 "N回目の書き込み内容"の後に書き込みが
    # 行われた場合でも、以下のテストに成功する
    handle.write.assert_has_calls(expect_calls)
    # write()の呼び出し回数をテスト
    # expect_calls以外にwrite()の呼び出しがあった場合は、このテストに失敗する
    assert handle.write.call_count == len(expect_calls)
```

### 自モジュール以外がファイルに書き込んだデータのテスト
標準ライブラリ、外部モジュール等、`write()`が自モジュール以外で呼び出される場合でも、同様に書き込み内容がテストできます。

```python:tests/test_foo.py
from sample.foo import Foo


def test_save_json(mocker):
    value = 123

    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    foo = Foo()
    foo.value = value
    foo.save_json("path/to/file")

    handle = open_mock()
    # write()の引数から、実際に書き込まれたjsonを取得する
    # json.dumpメソッドでは、write()の呼び出しがトークン単位で分割されるため、
    # write()の引数から書き込まれたjson文字列を作成し、期待値と比較する

    # json.dumpメソッドにindent=2を設定しているため、期待値は以下のjson文字列となる
    expect = f'{{\n  "value": {value}\n}}'

    # call_args_listには、args、kwargsのlistがwrite()の呼び出しごとにtupleで格納されている
    # json文字列を取得するには、write()の第一引数 = args[0]を結合した文字列を取得すればよい
    actual = "".join([args[0] for (args, kwargs) in handle.write.call_args_list])

    # 期待値とのテスト
    assert actual == expect
```

標準ライブラリ等、ファイルの書き込み処理が信頼できるのであれば、書き込み処理を行うメソッドにpatchを適用し、そのMock化したメソッドの引数をテストしてもよいと思います。

```python:tests/test_foo.py
from sample.foo import Foo


def test_save_json(mocker):
    value = 123

    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)
    # json.dumpメソッドにpatchを適用
    # "sample.foo"パッケージ内でimportしている"json.dump"メソッドにpatchを適用するため、
    # 対象は"sample.foo.json.dump"となる
    json_dump_mock = mocker.patch("sample.foo.json.dump")

    foo = Foo()
    foo.value = value
    foo.save_json("path/to/file")

    # json.dumpメソッドの呼び出し時の引数をテスト
    # 引数: fp はopen_mockのハンドル(= open_mock())となる
    json_dump_mock.assert_called_once_with({"value": value}, open_mock(), indent=2)
```

### ファイル書き込み処理中に発生する例外のテスト
writeモックの`side_effect`に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

```python:tests/test_foo.py
import pytest

from sample.foo import Foo


def test_save_txt(mocker):
    path = Path("path/to/file")

    open_mock = mocker.patch("builtins.open", new=mocker.mock_open())

    # write.side_effectに例外オブジェクトを設定
    handle = open_mock()
    handle.write.side_effect = OSError()

    # OSErrorが送出されることをテスト
    with pytest.raises(OSError):
        foo = Foo()
        foo.save_txt(path)
```

## ファイル読み込み処理のテスト

### ファイルから読み込んたデータのテスト
mock_openのキーワード引数`read_data`に、ファイルから読み込まれたデータを設定することで、ファイル読み込み処理がテストできます。

```python:tests/test_foo.py
from sample.foo import Foo


def test_load_txt(mocker):
    value = 123

    # read_dataにファイルから読み込まれるデータを設定
    # 設定したデータがread()呼び出し時に返却される
    read_data = str(value)
    mocker.patch("builtins.open", new_callable=mocker.mock_open, read_data=read_data)

    foo = Foo()
    foo.load_txt("path/to/file")

    # ファイルから読み込まれたvalueのテスト
    assert foo.value == value
```

### 自モジュール以外がファイルから読み込んだデータのテスト
標準ライブラリ、外部モジュール等、`read()`が自モジュール以外で呼び出される場合でも、同様に読み込み処理がテストできます。

```python:tests/test_foo.py
from sample.foo import Foo


def test_load_json(mocker):
    value = 123

    # read_dataにファイルから読み込まれるデータを設定
    # 設定したデータがread()呼び出し時に返却される
    read_data = f'{{\n  "value": {value}\n}}'
    mocker.patch("builtins.open", new_callable=mocker.mock_open, read_data=read_data)

    foo = Foo()
    foo.load_json("path/to/file")

    # ファイルから読み込まれたvalueのテスト
    assert foo.value == value
```

標準ライブラリ等、ファイルの読み込み処理が信頼できるのであれば、`write()`の場合と同様に、読み込み処理を行うメソッドにpatchを適用し、そのMock化したメソッドが読み込まれたデータを返却してもよいと思います。

```python:tests/test_foo.py
from sample.foo import Foo


def test_load_json(mocker):
    value = 123

    # json.loadメソッドをモック化するためread_dataの設定は不要
    mocker.patch("builtins.open", new_callable=mocker.mock_open)
    # json.loadメソッドにpatchを適用
    # "sample.foo"パッケージ内でimportしている"json.load"メソッドに
    # patchを適用するため、対象は"sample.foo.json.load"となる
    # また、json_load_mockのreturn_valueにファイルから読み込まれたdictを設定
    mocker.patch("sample.foo.json.load", return_value={"value": value})

    foo = Foo()
    foo.load_txt("path/to/file")

    # ファイルから読み込まれたvalueのテスト
    assert foo.value == value
```

### ファイル読み込み処理中に発生する例外のテスト
mock_openが返却するhandleモックのread属性に適用されているモック（以降、readモック）に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

```python:tests/test_foo.py
import pytest

from sample.foo import Foo


def test_load_txt(mocker):
    value = 123
    path = Path("path/to/file")

    open_mock = mocker.patch("builtins.open", new_callable=mocker.mock_open)

    # read.side_effectに例外オブジェクトを設定
    handle = open_mock()
    handle.read.side_effect = OSError()

    # OSErrorが送出されることをテスト
    with pytest.raises(OSError):
        foo = Foo()
        foo.load_txt(path)
```

# 参考
https://docs.python.org/ja/3/library/unittest.mock.html#mock-open

https://docs.pytest.org/en/7.1.x/index.html

https://pytest-mock.readthedocs.io/en/latest/