🧪

Pytestでうまくモックができないとき

2022/03/21に公開

pytestでimportの方法に応じてモックの方法が変わるのでメモがてら残しておきました。

実行環境

python==3.8.6
pytest==7.1.1

特定のメソッドをモックする場合

ここではmodules/utils.pyにあるhelloメソッドをモックするとします

modules/utils.py
def hello(s: str):
    return "hello" + s

①import Aの場合

modules/greeting.py
import modules.utils

def greeting():
    h = modules.utils.hello("Aさん")
    return h
テストコード
import pytest
from modules.greeting import greeting
import modules.utils

def mock_func(arg):
    return "mock_func"

def test_greeting(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setattr(modules.utils, "hello", mock_func)
    ret = greeting()
    assert ret == "mock_func"

②from B import Aの場合

modules/greeting.py
from modules.utils import hello

def greeting():
    h = hello("Aさん")
    return h
テストコード
import pytest
from modules.greeting import greeting

def mock_func(arg):
    return "mock_func"

def test_greeting(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setattr("modules.greeting.hello", mock_func)
    ret = greeting()
    assert ret == "mock_func"

③ ②の失敗例

modules/greeting.py
from modules.utils import hello

def greeting():
    h = hello("Aさん")
    return h
テストコード
import pytest
import modules.utils

def mock_func(arg):
    return "mock_func"

def test_greeting(monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setattr(modules.utils, "hello", mock_func)
    from modules.greeting import greeting
    ret = greeting()
    assert ret == "mock_func"

def test_greeting_2():
    from modules.greeting import greeting
    ret = greeting()
    assert ret == "helloAさん"

比較

①ではテストコードで対象モジュールをimportしてモックします
monkeypatch.setattr(modules.utils, "hello", mock_func)
直接importを書くのでIDEによってはコードジャンプで探しやすかったり、命名変更にも柔軟に対応できます。

②は文字列で直接指定します。
monkeypatch.setattr("modules.greeting.hello", mock_func)
まず文字列での指定なのでコードジャンプや命名変更に弱いです。
そしてややこしいのが、メソッドを使う側(modules.greeting)を指定します。 イメージとしてはfrom B import Aを実行した瞬間そのファイルでBをローカル名前空間として束縛するのですが、そのときに参照も固定してしまうようです。なのでモック対象はメソッドを使う側に対して行うようにします。

③は②の失敗例なのですがこのままだと正しく動作します。 しかし他のテストでgreetingを使うとなんとモックされたままになってしまいます。
pythonは同一セッション内ですでにimportしているメソッド・属性があった場合(ここではgreeting)それを再利用してしまいます。実際にデバッグ実行すると同一の参照であることを確認することができます。

この例ではtest_greeting,test_greeting_2は単体で成功しますが、2つのテストを逐次走らせると失敗します。流れとしては

  1. test_greetingでhelloに対してモックされる、その後greetingをimportするのでこの地点でgreeting内のhelloはモックされている
  2. test_greeting_2で再度greetingをimportするが、1ですでにimportされているのでpythonはそれを参照する。1でモックされているものを参照するので期待通りではない

②の場合は文字列で指定することでimport後でもモックされ、その後monkeypatchが破棄されることで戻る仕様に?おそらくなっているようです。また①のようにモック対象(ここではhello)を直接importしてなければモックによって参照先を上書きすることができました。

個人的にはimportの方法は②のほうが必要なメソッドなどを明示的に指定できるのでわかりやすいのですが、モック方法は①のほうが直感的で分かりやすいと感じます。とはいえテストコードのために実際のコードを分かりづらくはしたくないですし、モックを多用するケースが多くないのであれば②の方法を取るのが良いと思います。

参考

https://docs.python.org/ja/3/reference/import.html

Discussion