2
1

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 1 year has passed since last update.

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

Last updated at Posted at 2022-07-12

mock_openについて

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

mock_openの使用方法

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

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

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

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

mock_openのパッチ適用方法

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

コンテキストマネージャーとしてmock_openを適用

with文を使用してmock_openを適用します。
パッチの適用範囲はwith文のブロック内に限定されます。

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節で受け取ることも可能です。

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

関数デコレータを使用してmock_openを適用

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

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()呼び出し以降となります。

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パッケージがインストールされていない環境では、前述のパッチ適用方法を参考に、適宜読み替えてください。

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

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()呼び出し時の引数がテストできます。

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に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

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モック)を使用することで、ファイルに書き込まれた内容がテストできます。

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を期待値と比較することで、書き込み内容がテストできます。

    # 書き込み内容 = 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()の呼び出し回数もテストしてください。

    # 期待値となる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()が自モジュール以外で呼び出される場合でも、同様に書き込み内容がテストできます。

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化したメソッドの引数をテストしてもよいと思います。

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に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

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に、ファイルから読み込まれたデータを設定することで、ファイル読み込み処理がテストできます。

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()が自モジュール以外で呼び出される場合でも、同様に読み込み処理がテストできます。

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化したメソッドが読み込まれたデータを返却してもよいと思います。

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モック)に例外オブジェクトを指定することで、例外発生時の動作をテストできます。

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)

参考

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?