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文のブロック内に限定されます。
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を適用します。
パッチの適用範囲は関数内すべてとなります。
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()
呼び出し以降となります。
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パッケージがインストールされていない環境では、前述のパッチ適用方法を参考に、適宜読み替えてください。
テスト対象のソースコード
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()
呼び出し時の引数がテストできます。
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
に例外オブジェクトを指定することで、例外発生時の動作をテストできます。
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モック)を使用することで、ファイルに書き込まれた内容がテストできます。
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()
が自モジュール以外で呼び出される場合でも、同様に書き込み内容がテストできます。
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化したメソッドの引数をテストしてもよいと思います。
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
に例外オブジェクトを指定することで、例外発生時の動作をテストできます。
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
に、ファイルから読み込まれたデータを設定することで、ファイル読み込み処理がテストできます。
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()
が自モジュール以外で呼び出される場合でも、同様に読み込み処理がテストできます。
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化したメソッドが読み込まれたデータを返却してもよいと思います。
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モック)に例外オブジェクトを指定することで、例外発生時の動作をテストできます。
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)
参考