pytestでは、monkeypatch
を使ってmockを作成できます。
今回は、monkeypatch.setattr()
を使って、
- プロダクションコードのメソッドや関数
- Python標準モジュールのメソッド
を差し替え(mock化)してみた時のメモです。
目次
環境
使い方
プロダクションコードのメソッドを差し替え
プロダクションコード
class Target(object): CONST_VALUE = 'foo' def get_const(self): return self.CONST_VALUE
のget_const()
メソッドを差し替えるには、
- ダミーメソッドを用意して差し替え
- lambdaを使って差し替え
のどちらかを使います。
# ダミーメソッドを用意して差し替える場合 # pytestのテストコードの引数に、`monkeypatch`を追加 def test_patch_by_dummy_function(self, monkeypatch): # run_dummyというダミーメソッドを用意し、この中で差し替える戻り値を設定 # Target.get_constは引数selfがあるため、差し替えメソッドでも引数(arg)を用意 def run_dummy(arg): return 'ham' # monkeypatch.setattr()を使って差し替え # 第一引数:importしたプロダクションコードのクラス # 第二引数:差し替え対象のメソッド # 第三引数:差し替えたダミーメソッド monkeypatch.setattr(Target, 'get_const', run_dummy) sut = Target() actual = sut.get_const() assert actual == 'ham' # lambdaを使って差し替える場合 def test_patch_by_lambda(self, monkeypatch): expected = 'ham' # ダミーメソッドを用意する手間を省くために、lambdaを使っても良い # lambdaでも引数は必要(今回の場合、`x`) 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する # さらに戻り値は返さないという、面倒なプライベートメソッド 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() # 差し替えた例外AssertionErrorが発生したかをチェック with pytest.raises(AssertionError) as excinfo: sut.raise_exception() assert 'ham' == str(excinfo.value)
プロダクションコードがimportしているモジュールの属性を差し替え
プロダクションコード(target.py)で
# target.py import outer_import class Target(object): def get_outer_import(self): return outer_import.FOO # outer_import.py 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)で
# target.py from outer_from_import import BAR class Target(object): def get_outer_from_import(self): return BAR # outer_from_import.py 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): # pytestのcapsysを併用した例 # http://doc.pytest.org/en/latest/capture.html def print_dummy(arg): print('ham') # python3ではprintは式なので、lambdaに指定可能 monkeypatch.setattr(Target, 'write_stdout', lambda x: print('ham')) # python2ではprintは文なので、lamdbaに指定できないことから、ダミー関数を使う # monkeypatch.setattr(Target, 'write_stdout', print_dummy) 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') # E assert "b'\\x82\\xd9\\x82\\xb0'\n" == b'\x82\xd9\x82\xb0\n' # E + where b'\x82\xd9\x82\xb0\n' = <built-in method encode of str object at 0x101bb4990>('cp932') # E + where <built-in method encode of str object at 0x101bb4990> = 'ほげ\n'.encode
関数の差し替え
プロダクションコード
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だけで差し替えたい場合は、以下が参考にして差し替えます。
- How to monkeypatch python’s datetime.datetime.now with py.test? - Stack Overflow
- mocking - Why python’s monkeypatch doesn’t work when importing a class instead of a module? - Stack Overflow
- testing - Python: Trying to mock datetime.date.today() but not working - Stack Overflow
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