🐍

python.unittest.patch でテスト対象の import をモックする

2025/03/14に公開1

記事の概要

  • python.unittest でユニットテストを実装するにあたって、テスト対象モジュールで import しているモジュールをモックしたい場合の方法
    • 実行環境に依存していて、ローカル環境では import できないモジュールのユニットテストを書かないといけない場合とか
  • global 変数を使うことにあんるが、それを回避しようとして、 python.unittest のライフサイクルに従わないと悪影響があるというおまけの話

python.unittest.patch でテスト対象の import をモックする方法

  1. setUpModule() で global 変数として patch を定義して start
  2. その後、モック対象のモジュールを import
  3. 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

結論

Discussion