🐍
python.unittest.patch でテスト対象の import をモックする
記事の概要
-
python.unittest
でユニットテストを実装するにあたって、テスト対象モジュールで import しているモジュールをモックしたい場合の方法- 実行環境に依存していて、ローカル環境では import できないモジュールのユニットテストを書かないといけない場合とか
- global 変数を使うことにあんるが、それを回避しようとして、
python.unittest
のライフサイクルに従わないと悪影響があるというおまけの話
python.unittest.patch でテスト対象の import をモックする方法
- setUpModule() で global 変数として patch を定義して start
- その後、モック対象のモジュールを import
- tearDownModule() で patch を stop
クラス単位でモックしたいなら setUpClass() / tearDownClass() を使う。
patch_setupmodule.py
import unittest
from unittest.mock import MagicMock, patch
def setUpModule():
"""モジュールレベルのセットアップ"""
global patcher
patcher = patch.dict('sys.modules', {'fake_module': MagicMock()})
patcher.start()
# `setUpModule()` 内で `fake_module` をインポート
global fake_module
import fake_module
def tearDownModule():
"""モジュールレベルのクリーンアップ"""
global patcher
patcher.stop()
class TestFakeModule(unittest.TestCase):
"""fake_module のモックをテストする"""
def test_fake_module_mocked(self):
"""fake_module がパッチされていることを確認"""
global fake_module
self.assertIsInstance(fake_module, MagicMock)
global 変数を回避する方法(やっちゃだめ)
みたらわかるように global 変数を使いまくるので、linter によっては警告が出まくります。
それを嫌がって global 変数を使わない方法で実装したのが以下
- patch の start と import をモジュールのトップレベルで実施することで global を使わずに実行できます。
patch_top.py
import unittest
from unittest.mock import MagicMock, patch
# モジュールのトップレベルでパッチ適用(問題があるパターン)
patcher = patch.dict('sys.modules', {'fake_module': MagicMock()})
patcher.start()
# `fake_module` をインポート
import fake_module
def tearDownModule():
"""モジュールレベルのクリーンアップ"""
global patcher
patcher.stop()
class TestFakeModule(unittest.TestCase):
"""fake_module のモックをテストする"""
def test_fake_module_mocked(self):
"""fake_module がパッチされていることを確認"""
self.assertIsInstance(fake_module, MagicMock)
ただ、モジュールのトップレベルで処理すると、 python.unittest
のライフサイクル外になるので、 patcher.stop() が適切に行われない可能性があります。
検証する
OKパターン、NGパターンの後に、以下の検証コードを実行して動作を見ます。
check_patch.py
import sys
import unittest
from unittest.mock import MagicMock
try:
import fake_module # `patcher.stop()` が機能していれば、ここで ImportError になるはず
print("[ERROR] fake_module is still patched! patcher.stop() did not work.")
except ModuleNotFoundError:
print(
"[SUCCESS] fake_module is not available. patcher.stop() worked correctly."
)
class TestFakeModule(unittest.TestCase):
def test_fake_module_mocked(self):
self.assertIsInstance(fake_module, MagicMock)
setUpModule() を使った場合
ファイル構成はこんな感じ。ファイル名の頭に数字をつけているのは unittest の実行順を考慮したものです。
patch_module/
|- 1_patch_setupmodule.py
|- 2_check_patch.py
patcher.stop() が実行されているため、 check_patch.py では fake_module
をインポートすることができません。
$ python -m unittest *.py
[SUCCESS] fake_module is not available. patcher.stop() worked correctly.
.E
======================================================================
ERROR: test_fake_module_mocked (2_check_patch.TestFakeModule.test_fake_module_mocked)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/keita/unittest_module_patch_sample/patch_module/2_check_patch.py", line 19, in test_fake_module_mocked
self.assertIsInstance(fake_module, MagicMock)
^^^^^^^^^^^
NameError: name 'fake_module' is not defined
----------------------------------------------------------------------
Ran 2 tests in 0.003s
FAILED (errors=1)
setUpModule() を使わない場合
patch_top/
|- 1_patch_top.py
|- 2_check_patch.py
check_patch.py でも fake_module
がインポートできてしまいました。
patcher.stop() が呼び出されていないようです。
$ python -m unittest *.py
[ERROR] fake_module is still patched! patcher.stop() did not work.
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
結論
-
python.unittest
で実行順やスコープを意識する必要がある場合は setUpXX() / tearDownXX() 関数をきちんと使いましょう。 - 余談ですが、 TestSuit() を使うと、テスト関数の実行順も制御することができます。
Discussion
検証に使ったソースコードは以下で公開しています。好きに使ってください。