Python + pytestで、monkeypatch.setattr()を使ってみた

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()を差し替えます。

この場合、以下を組み合わせて差し替えます。

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