logo

202334

Pythonのunittest.mock.patchではどこにパッチするかが重要

Python 公式ドキュメントの unittest.mock のページにドンピシャの内容が書いてありますが、なかなか気づけずにハマってしまっていたのでメモです。

unittest.mock.patch でパッチしたけど当たってない気がする人は参考にしてみてください。

下記の引用に要点が凝縮されています。

どこにパッチするか

patch() は (一時的に) ある 名前 が参照しているオブジェクトを別のものに変更することで適用されます。任意のオブジェクトには、それを参照するたくさんの名前が存在しえます。なので、必ずテスト対象のシステムが使っている名前に対して patch しなければなりません。

基本的な原則は、オブジェクトが ルックアップ されるところにパッチすることです。その場所はオブジェクトが定義されたところとは限りません。

つまり、宣言した場所ではなく import している側から見たオブジェクトの位置を指定しなさい、ということです。ただ、from a import SomeClass とするか、import a して a.SomeClass とするかでパッチの当て方が変わってくるので注意が必要です。

以下のファイルがある想定で実験してみます。

├─ a.py ├─ b.py ├─ c.py ├─ test_b.py ├─ test_c.py
terminal = false [[fetch]] from = "../../../assets/posts/2023/03/a.py" to_file = "./a.py" [[fetch]] from = "../../../assets/posts/2023/03/b.py" to_file = "./b.py" [[fetch]] from = "../../../assets/posts/2023/03/c.py" to_file = "./c.py" [[fetch]] from = "../../../assets/posts/2023/03/test_b.py" to_file = "./test_b.py" [[fetch]] from = "../../../assets/posts/2023/03/test_c.py" to_file = "./test_c.py"

a.pySomeClass はテスト対象システムが依存しているクラスです。これがモックの対象となるクラスです。

a.py
class SomeClass:
    def some_method(self):
        raise NotImplementedError()

b.pySystemUnderTest はその名の通りテスト対象システムです。b.py では from a import SomeClass で import してから SomeClass() でインスタンス化しています。

この状態で a.SomeClass を patch() を使って mock out してもテストには影響しません。モジュール b はすでに 本物の SomeClass への参照を持っていて、パッチの影響を受けないからです。

重要なのは、 SomeClass が使われている (もしくはルックアップされている) 場所にパッチすることです。この場合、 some_function はモジュール b の中にインポートされた SomeClass をルックアップしています。

なのでパッチする時は @patch("b.SomeClass") とします。

b.py
from a import SomeClass


class SystemUnderTest:
    def some_function(self):
        sc = SomeClass()
        return sc.some_method()

c.pySystemUnderTest もその名の通りテスト対象システムです。c.py では import a で import してから a.SomeClass() でインスタンス化しています。

この場合、パッチしたいクラスはそのモジュールからルックアップされているので、 a.SomeClass をパッチする必要があります

なので @patch("a.SomeClass") と書いてパッチを当てます。

c.py
import a


class SystemUnderTest:
    def some_function(self):
        sc = a.SomeClass()
        return sc.some_method()

それぞれに対するテストを書いて確かめてみます。TestBtest_patching_a はパッチが当たらないので失敗するはずです。

test_b.py
import unittest
from unittest.mock import patch

from b import SystemUnderTest


class TestB(unittest.TestCase):
    @patch("a.SomeClass")
    def test_patching_a(self, some_class_mock):
        some_class_mock_instance = some_class_mock.return_value
        some_class_mock_instance.some_method.return_value = "mock"
        sut = SystemUnderTest()
        # Call below will raise NotImplementedError since it is not patched
        actual = sut.some_function()
        assert actual == "mock"

    @patch("b.SomeClass")
    def test_patching_b(self, some_class_mock):
        some_class_mock_instance = some_class_mock.return_value
        some_class_mock_instance.some_method.return_value = "mock"
        sut = SystemUnderTest()
        actual = sut.some_function()
        assert actual == "mock"

TestCtest_patching_c ではパッチを当てることに失敗します。

test_c.py
import unittest
from unittest.mock import patch

from c import SystemUnderTest


class TestC(unittest.TestCase):
    @patch("a.SomeClass")
    def test_patching_a(self, some_class_mock):
        some_class_mock_instance = some_class_mock.return_value
        some_class_mock_instance.some_method.return_value = "mock"
        sut = SystemUnderTest()
        actual = sut.some_function()
        assert actual == "mock"

    @patch("c.SomeClass")  # will raise AttributeError
    def test_patching_c(self, some_class_mock):
        some_class_mock_instance = some_class_mock.return_value
        some_class_mock_instance.some_method.return_value = "mock"
        sut = SystemUnderTest()
        actual = sut.some_function()
        assert actual == "mock"

実行します。

main.py
from unittest import TestLoader
from unittest import TextTestRunner


loader = TestLoader()
test = loader.discover(".")
runner = TextTestRunner()
runner.run(test)

期待通りの結果が得られていますね。Python の import まわりはやっぱりなんか面倒くさい・・・。

参考文献