pytestのどこにパッチを当てるかを深掘りする

以下の部分を読んでいてあまり理解できなかったので、実際にサンプルコードを作って考えてみることにしました。

https://docs.python.org/ja/3.11/library/unittest.mock.html#where-to-patch

今回、理解を深めるために、以下のコードを用意しました。

a.py

class SomeClass:
    def method(self):
        return "original value"

b.py

from a import SomeClass

def some_function():
    instance = SomeClass()
    return instance.method()

c.py

import a

def another_function():
    instance = a.SomeClass()
    return instance.method()

今回は、b.pyとc.pyにテストコードを書くことでドキュメントを理解します。

ここでのポイントは、a.pyのSomeClassをどのようにMock(パッチを当てる)するかというところです。

ドキュメントを読むと、以下の一節があります。

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

b.pyとc.pyにテストを実装することで、この一節が理解できました。

b.pyのテストを実装する

まずは、patchの場所が正しくない例として以下を挙げます。

b_test.py

from unittest.mock import patch
import b

def test_some_function2():
    with patch('a.SomeClass') as MockClass:
        MockClass.return_value.method.return_value = "mocked value"
        result = b.some_function()
        assert result == "mocked value"  # このアサーションは失敗する

a.pyにあるSomeClassをMockすることを考えると、上記のように実装してしまいそうになります。またドキュメントを参照することになりますが、ちょうど上記のコードが理解に役立ちます。

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

上記より、正しい実装は以下となります。

from unittest.mock import patch
import b

def test_some_function1():
    with patch('b.SomeClass') as MockClass:
        MockClass.return_value.method.return_value = "mocked value"
        result = b.some_function()
        assert result == "mocked value"

c_test.py

c.pyはどのようにテストをすればいいでしょうか? b.pyとc.pyの違いはa.pyのimport方法に違いがあります。

間違った実装方法を先に表すと、以下です。

from unittest.mock import patch
import c

def test_another_function():
    # 誤ったパッチの適用なので
    with patch('c.SomeClass') as MockClass:
        MockClass.return_value.method.return_value = "mocked value"
        result = c.another_function()
        assert result == "mocked value"

上記の実装ではそもそも別のエラーが発生します。

FAILED c_test.py::test_another_function – AttributeError: <module ‘c’ from ‘/workspaces/pytest0406_using_patch/src/c.py’> does not have the attribute ‘SomeClass

そもそもc.SomeClassというのがないので当然ですが、b.pyのテスト実装ではbの中に対してパッチを当てていたので、今回もその印象のままに実装してしまいそうです。

今回のケースでは、以下のように実装するのが正しいやり方です。

from unittest.mock import patch
import c

def test_another_function():
    with patch('a.SomeClass') as MockClass:
        MockClass.return_value.method.return_value = "mocked value"
        result = c.another_function()
        assert result == "mocked value"

理解のための補助線としてインポートパターンを分けて考える

  1. 直接インポートパターンfrom xxx import yyyとある場合)
    • このパターンでは、モジュール(b.py)が別のモジュールからクラス(SomeClass)を直接インポートしています(from a import SomeClass)。テスト対象のコードが使用する名前空間内でオブジェクトが定義されています。したがって、その名前空間内でパッチを適用する必要があります。この場合、b.SomeClassをパッチします。
  2. モジュール経由パターンimport xxxだけの場合)
    • このパターンでは、モジュール(c.py)が他のモジュール全体をインポートしています(import a)、そしてそのモジュール内のクラス(SomeClass)を使っています。ここでは、オリジナルのモジュール名(a)を使ってクラスにアクセスしているため、そのモジュール内で定義されたオブジェクトをパッチする必要があります。この場合、a.SomeClassをパッチします。

まとめ

この記事では、unittest.mock.patchを使用してモックを適切に適用する方法について詳しく解説しました。重要なポイントは、モックを適用する場所はオブジェクトがルックアップされる場所であるということです。

  1. 直接インポートパターンでは、オブジェクトがテスト対象のモジュール内で直接インポートされているため、そのモジュール内でパッチを適用する必要があります。つまり、テスト対象のモジュールが対象となる。
  2. モジュール経由パターンでは、オブジェクトが元のモジュール経由で使用されているため、元のモジュールでパッチを適用する必要があります。つまり、テストコードの参照元が対象となる。