私の職場では主にPythonでシステム開発をしており、テストではpytestを利用する事が多いのですが、表題のようにMock化したい関数が上手くMock化出来ずに困ってしまう事があります。特に厄介なのが、Mock化されない根本原因が分からない場合は色々とソースコードを変更しながら期待する挙動になるまでトライ&エラーを繰り返すしかないという点で、毎回この問題が解決(大抵の場合は根本原因が分からないまま)するまでにとても時間がかかっていました。
流石にこの状況はまずいと思い色々調べた結果、この問題を再現性のある形で解決する方法を見つける事が出来たので、その内容を記事にまとめてみました。
※本記事は、下記の環境で動作確認を実施しています。
Python 3.9.11
pytest 7.1.3
pytest-mock 3.10.0
今回の検証の為に、下記の構成のプログラムを用意しました。
.
├── src
│ ├── __init__.py
│ ├── functions.py
│ └── main.py
└── test
├── __init__.py
├── test_main.py
def target_method():
return 'Mock化されていません。'
from .functions import target_method
def sample():
return target_method()
from src.main import sample
def test_sample(mocker):
message = 'Mock化されました。'
mocker.patch("src.functions.target_method", return_value=message)
result = sample()
assert result == message
このプログラムでは、test_sample関数の中にsample関数の内容を検証するロジックが実装されています。test_sample関数の中でtarget_method関数をMock化している為、期待通りにtarget_method関数がMock化されていれば、sample関数は「Mock化されました。」という文字列を返却するので、このテストは成功するはずです。
しかし、実際にpytestを実行してみると、下記のようにテストは失敗します。
※エラー内容から察するに、Mock化出来ていないようです。
FAILED test/test_main.py::test_sample - AssertionError: assert 'Mock化されていません。' == 'Mock化されました。'
上記の事象ですが、実はモジュールのインポートのやり方によって挙動が変化します。
from XX import YY のようにインポートすると、インポート元のモジュールと切り離されたような状態で関数が読み込まれるらしく、その後でインポート元のモジュールで定義された関数をMock化してもインポート先のモジュールにはその内容が反映されません。
※今回の例で言うと、sample関数のインポート後にfunctionsモジュールの方で定義されたtarget_method関数をMock化しても、それより前の時点でインポートされていたsample関数の中から呼ばれるtarget_method関数はMock化される前の関数が呼び出されます。
本記事では、期待通りにtarget_method関数をMock化する方法を、3つご紹介します。
importlibを利用する方法
単純に考えると、target_method関数をMock化した後に再度sample関数をインポートすれば良いと思うのですが、Pythonは言語仕様的に、一度読み込まれたモジュールを再度読み込もうとすると、モジュールの読み込みをスキップするようになっているのでこの方法は上手くいきません。
そこで、importlibを利用してモジュールをインポートして、target_method関数をMock化した後にリロードする方法を試してみると、上手くいく事が確認出来ました。(この方法だと、reload関数を実行すれば何度でもモジュールの再読み込みが出来るからです。)
※このやり方は直感的で理解しやすい為、かなりお勧めです。
def target_method():
return 'Mock化されていません。'
from .functions import target_method
def sample():
return target_method()
# from src.main import sample
from importlib import import_module, reload
module = import_module('src.main')
sample = getattr(module, 'sample')
def test_sample(mocker):
message = 'Mock化されました。'
mocker.patch("src.functions.target_method", return_value=message)
reload(module)
result = sample()
assert result == message
インポートのやり方を変更する
先ほど、モジュールのインポートのやり方によって挙動が変化すると言いましたが、今回の問題はインポートのやり方を変更する事でも解消する事が出来ます。
import XX の型式でインポートすると、target_method関数はモジュールオブジェクト経由でアクセスする事になるので、特別な考慮をしなくてもMock化された関数を実行する事が出来ます。
def target_method():
return 'Mock化されていません。'
import src.functions as alias
def sample():
return alias.target_method()
from src.main import sample
def test_sample(mocker):
message = 'Mock化されました。'
mocker.patch("src.functions.target_method", return_value=message)
result = sample()
assert result == message
Mock化する対象を変更する
from XX import YY のようにインポートすると、インポート元のモジュールと切り離されたような状態で関数が読み込まれるのであれば、そっちの関数をMock化すれば良いのでは?というアイデアも出てくると思います。下記のソースコードでは、Mock化する対象をsrc.functions.target_method からsrc.main.target_method に変更しているだけですが、このやり方でも上手くMock化されていることを確認出来ます。
※ただし、このやり方はPythonに詳しくない人からするとものすごく分かりにくいと思うので、将来的にどのような人がメンテするか分からない状況(商用案件のほとんどがそうだと思いますが。。)では、使わない方が良いと思われます。
def target_method():
return 'Mock化されていません。'
from .functions import target_method
def sample():
return target_method()
from src.main import sample
def test_sample(mocker):
message = 'Mock化されました。'
mocker.patch("src.main.target_method", return_value=message)
result = sample()
assert result == message
上記でご紹介したやり方を見つけた後は、業務でpytestを書く際にMockで苦しめられる事が激減したので、同じところで悩んでいる方には是非試してみて頂きたいです。
参考資料
上記のサイトでは、from XX import YY のインポートについて、下記の2つの処理は等価であると説明しています。※残念ながら、現時点で公式ドキュメントでは同様の記述を見つけられず。。
from mod import name1, name2
import mod
name1 = mod.name1
name2 = mod.name2
del mod
こうしてみると、確かに from XX import YY の形式でインポートした関数はインポート先モジュールのグローバル変数に代入される事になるので、インポート元モジュールの関数をMock化しても意味がないのは納得ですね。