pytestでは、monkeypatch
を使ってmockを作成できます。
今回は、monkeypatch.setattr()
を使って、
- プロダクションコードのメソッドや関数
- Python標準モジュールのメソッド
を差し替え(mock化)してみた時のメモです。
目次
環境
使い方
プロダクションコードのメソッドを差し替え
プロダクションコード
class Target(object):
CONST_VALUE = 'foo'
def get_const(self):
return self.CONST_VALUE
のget_const()
メソッドを差し替えるには、
- ダミーメソッドを用意して差し替え
- lambdaを使って差し替え
のどちらかを使います。
def test_patch_by_dummy_function(self, monkeypatch):
def run_dummy(arg):
return 'ham'
monkeypatch.setattr(Target, 'get_const', run_dummy)
sut = Target()
actual = sut.get_const()
assert actual == 'ham'
def test_patch_by_lambda(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr(Target, 'get_const', lambda x: expected)
sut = Target()
actual = sut.get_const()
assert actual == expected
標準ライブラリを差し替え
プロダクションコード
import platform
class Target(object):
def get_platform(self):
return platform.system()
で使われている標準モジュールplatform.system()
の戻り値をham
という文字列に差し替えるには、
import platform
class Test_target:
def test_patch_standard_library(self, monkeypatch):
monkeypatch.setattr(platform, 'system', lambda: 'ham')
とします。
差し替え対象モジュールをimportせずに差し替え
モジュールをimportせず、モジュール名を文字列で渡しても差し替えが可能です。
例えば、テストコードでimport os
せずに、os.sysmtem()
を差し替える場合は、
def test_patch_no_import(self, monkeypatch):
monkeypatch.setattr('os.system', lambda: expected)
でOKです。
定数の差し替え
プロダクションコード
class Target(object):
CONST_VALUE = 'foo'
の定数CONST_VALUE
を差し替えます。
定数なので、lambdaを使う必要はないです。
def test_const(self, monkeypatch):
monkeypatch.setattr(Target, 'CONST_VALUE', expected)
複数の引数を持つメソッドを差し替え
プロダクションコード
class Target(object):
def get_multi_args(self, foo, bar):
return ''.join([foo, bar])
の複数の引数を持つget_multi_args()
メソッドを差し替えます。
同じ数の引数か可変長引数をlambdaに渡します。
def test_function_multi_args1(self, monkeypatch):
monkeypatch.setattr(Target, 'get_multi_args', lambda x, y, z: expected)
def test_function_multi_args2(self, monkeypatch):
monkeypatch.setattr(Target, 'get_multi_args', lambda *_: expected)
プライベートメソッドの差し替え
プロダクションコードに面倒なプライベートメソッド__run_complex()
があり、テストではこのプライベートメソッドで何も処理してほしくないとします。
class Target(object):
def call_private_function(self):
self.__run_complex()
return self.CONST_VALUE
def __run_complex(self):
raise Exception
この場合、プライベートメソッドをマングリングした表現で指定し、Noneを返すように差し替えます。
def test_patch_private_function(self, monkeypatch):
monkeypatch.setattr(Target, '_Target__run_complex', lambda x: None)
複数の戻り値を持つメソッドの差し替え
プロダクションコード
class Target(object):
def get_multi_return_values(self):
return ('foo', 'bar')
の複数の戻り値を持つget_multi_return_values()
メソッドを差し替えるには、戻り値をタプルなどで返します。
def test_patch_multi_return_values(self, monkeypatch):
monkeypatch.setattr(Target, 'get_multi_return_values', lambda x: ('ham', 'spam'))
例外を送出するように差し替え
プロダクションコード
class Target(object):
def raise_exception(self):
raise RuntimeError('foo')
で例外RuntimeError
を送出するraise_exception()
メソッドを、別の例外AssertionError
を送出するように差し替えます。
この場合、ジェネレータ式のthrow()
を使ったlambdaのワンライナーを使います。
def test_patch_exception(self, monkeypatch):
monkeypatch.setattr(Target, 'raise_exception', lambda x: (_ for _ in ()).throw(AssertionError('ham')))
sut = Target()
with pytest.raises(AssertionError) as excinfo:
sut.raise_exception()
assert 'ham' == str(excinfo.value)
プロダクションコードがimportしているモジュールの属性を差し替え
プロダクションコード(target.py)で
import outer_import
class Target(object):
def get_outer_import(self):
return outer_import.FOO
FOO = 'baz'
のように、outer_import.pyをimport
しているouter_import.FOO
を差し替えます。
方法は、ここまで見てきたものと同じです。
def test_patch_import(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr(outer_import, 'FOO', expected)
sut = Target()
actual = sut.get_outer_import()
assert actual == expected
プロダクションコードがfrom … importしているモジュールの属性を差し替え
前項と異なり、プロダクションコード(target.py)で
from outer_from_import import BAR
class Target(object):
def get_outer_from_import(self):
return BAR
BAR = 'baz'
のように、outer_from_import.pyをfrom ... import
しているouter_from_import.BAR
を差し替えます。
今まで通り、
def test_patch_from_import(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr(outer_from_import, 'BAR', expected)
sut = Target()
actual = sut.get_outer_from_import()
assert actual == expected
としても差し替わらず、以下のエラーとなります。
E assert 'baz' == 'ham'
E - baz
E + ham
from ... import
した場合には、モジュール名前空間が変わるためです。
参考:そんなpatchで大丈夫か? (mockについてのメモ〜後編〜) - Qiita
そこで、monkeypatch.setattr('target.BAR', expected)
のように差し替えます。
def test_patch_from_import(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr('target.BAR', expected)
sut = Target()
actual = sut.get_outer_from_import()
assert actual == expected
標準出力の差し替え
プロダクションコード
class Target(object):
def write_stdout(self):
print('foo')
のwrite_stdout()
で標準出力へ出力している内容を差し替えます。
この場合、pytestのcapsys
を併用して差し替えと検証をします。
Capturing of the stdout/stderr output ? pytest documentation
def test_patch_stdout(self, monkeypatch, capsys):
def print_dummy(arg):
print('ham')
monkeypatch.setattr(Target, 'write_stdout', lambda x: print('ham'))
sut = Target()
actual = sut.write_stdout()
actual, _ = capsys.readouterr()
assert actual == 'ham\n'
なお、プロダクションコードが標準出力へ文字コードcp932などで出していた場合、
class Target(object):
def write_cp932_stdout(self):
print('ほげ'.encode('cp932'))
差し替えてもうまく動作しません。
def test_stdout_cp932(self, capsys):
sut = Target()
actual = sut.write_cp932_stdout()
actual, _ = capsys.readouterr()
assert actual == 'ほげ\n'.encode('cp932')
関数の差し替え
プロダクションコード
def standard_function():
return 'standard'
の関数standard_function
を差し替えます。
import functions
def test_standard_function(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr(functions, 'standard_function', lambda: expected)
actual = functions.standard_function()
assert actual == expected
プライベート関数の差し替え
プロダクションコードの
def __private_function():
return 'private'
プライベート関数(先頭にアンダースコアが2つある関数)である__private_function()
を差し替えます。
この場合、以下を組み合わせて差し替えます。
- テストコードへプライベート関数をimportする時は、
from ... import ... as
を使う
sys.modules[__name__]
を使って、現在のモジュールオブジェクトを取得して差し替え
from functions import __private_function as pf
def test_private_function(self, monkeypatch):
expected = 'ham'
monkeypatch.setattr(sys.modules[__name__], 'pf', lambda: expected)
actual = pf()
assert actual == expected
sys.exc_infoの差し替え
pytestが内部で使っているようで、
def test_do_not_patch_exc_info(self, monkeypatch):
expected = ('ham', 'spam', 'egg')
def patch_exc_info(arg):
return expected
monkeypatch.setattr(sys, 'exc_info', patch_exc_info)
sut = Target()
actual = get_sys_exc_info()
とすると、実行ログで
- テストは通る
- 以下のエラーが出て、pytestの結果が乱れる
となります。そのため、差し替えないほうがいいようです。
test_monkeypatch.py ...............
========================== 15 passed in 0.09 seconds ===========================
...
Traceback (most recent call last):
TypeError: patch_exc_info() missing 1 required positional argument: 'arg'
During handling of the above exception, another exception occurred:
datetimeの差し替え
datetimeの差し替えはfreezegun
を使うと便利です。
spulec/freezegun: Let your Python tests travel through time
ただ、pytestだけで差し替えたい場合は、以下が参考にして差し替えます。
def test_patch_datetime(self, monkeypatch):
expected = datetime.datetime.now()
class PatchedDatetime(datetime.datetime):
@classmethod
def now(cls):
return expected
monkeypatch.setattr('datetime.datetime', PatchedDatetime)
sut = Target()
actual = sut.get_current_datetime()
assert actual == expected
なお、datetime.datetimeはC拡張の組込型です。
そのため、
def test_cannot_patch_datetime(self, monkeypatch):
expected = datetime.datetime.now()
monkeypatch.setattr('datetime.datetime.now', lambda: expected)
sut = Target()
actual = sut.get_current_datetime()
assert actual == expected
としても、エラーとなります。
built-in object を拡張する禁断の果実を齧ろう - Qiita
E TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
GitHubに上げました。e.g._monkeypatch
ディレクトリの中が今回のファイルです。
thinkAmi-sandbox/python_pytest-sample
なお、プロダクションコードとテストコードは、同じディレクトリに入れてあります。
そのため、実行時PYTHONPATHを通すために、python -m pytest
で実行します。
python - PATH issue with pytest ‘ImportError: No module named YadaYadaYada’ - Stack Overflow