🐵

pytest:monkeypatchは直接呼び出し時に無効化される?

2024/10/06に公開

概要

pytestのmonkeypatchを使っていると、パッチを当てたはずの関数を呼び出した際にパッチが当たっていない現象に遭遇した。
状況を調べていると、その現象はパッチを当てた関数を直接呼び出した際に起こっていることがわかった。
そのため状況を簡略化してmonkeypatchのパッチすり抜け問題を検証した。

結論

パッチを当てた関数を "from" で "直接" 呼び出した場合、
パッチは適用されていない。

条件

関数f()にmonkeypatchを適応。

関数 g は関数 f を利用している。

ここで関数 f にmonkeypatchを適応した場合、関数fg の挙動はどうなるのだろうか?

呼び出し元

# infra.api.hoge.fooモジュール内部

def f():
    return 'original'

def g():
    return f()

呼び出し側

# モジュール単位での呼び出し
import infra.api.hoge.foo as hoge_foo
# 部分呼び出し
from infra.api.hoge.foo import f, g

def test_mock_f(monkeypatch):
    assert 'original' == f()
    monkeypatch.setattr(
        'infra.api.hoge.foo.f',
        lambda: 'mocked'
    )
    assert 'mocked' == f() # これは失敗

def test_mock_f_direct(monkeypatch):
    assert 'original' == hoge_foo.f()
    monkeypatch.setattr(
        'infra.api.hoge.foo.f',
        lambda: 'mocked'
    )
    assert 'mocked' == hoge_foo.f() # 成功

def test_mock_g(monkeypatch):
    assert 'original' == g()
    monkeypatch.setattr(
        'infra.api.hoge.foo.f',
        lambda: 'mocked'
    )
    assert 'mocked' == g() # 成功

テスト結果

パッチすり抜け問題を再現できた。

パッチを当てたはずの関数は直接呼び出し時にはモック化されておらず、
f を呼び出しているg についてはモック化が行われている。

test_mock_f

fのモック化が失敗している。

fはモック化されず、元の original が返されてしまっている。

test_mock_f_direct

こちらはfモック化が成功している。

戻り値は mocked に改変されている。

test_mock_g

fのモック化が成功している。

gの戻り値は正常に mocked に改変されてる

インポートの方式によって参照される関数が変わる?

test_mock_ftest_mock_f_with_import のモック化部分は両者同じだ。

monkeypatch.setattr(
        'infra.api.hoge.foo.f',
        lambda: 'mocked'
    )

だがモジュールの参照方法によって、モック化されるか否かの結果が変わっている。

assert 'mocked' == f() # これは失敗
assert 'mocked' == hoge_foo.f() # 成功

原因

結論から言うと、 from...import... というインポート方式の違いによる仕様が原因。
両者は共にインポートに使われるが、挙動が微妙に違うようだ。

from ...によるインポートの仕様

from infra.api.hoge.foo import f によるインポートは、fという名前をローカル側の名前空間にバインドする。
平たく言うとinfra.api.hoge.fooモジュールの関数fのショートカットをローカル側に名前fで登録したと言う具合だ。

この時点では参照先になんの乖離もない。

monkeypatch適応のタイミング

ここで問題なのは、monkeypatchを当てた時の挙動だ。
from infra.api.hoge.foo import f, g はmonkeypatchよりも先に実行される。
そしてその後、monkeypatchがfを置き換える。

さて、ここで f の参照先はどこを指しているでしょうか?

参照先は一体どこに?

当然、from ... import f のfの参照先は、
monkeypatchにより置き換えられた"mocked"な f を指しているものと思うだろう。

だが実際は、置き換え前の"original"fを指し続けている!

fromは呼び出された時点での参照先を頑なに持ち続ける。
だからモック化が失敗しているような挙動になる。

import ...によるインポートの仕様

import ...によりインポートされている関数は上のショートカットとは違い、常に実態を参照する。
そのため、monkeypatchによって関数が書き変わったとしてもその変化に追従することができる。
結果としてモック化は成功し期待通りの動作をすることとなる。

なぜtest_mock_g()は成功するのか?

パッチを当てた関数をfrom ...によるインポートしようとすると、
パッチが当たらずにすり抜けてしまうことがわかった。
だがそれならば、関数 gfrom ... によるインポートを行っているにもかかわらず、
なぜfのパッチを当てることに成功しているのだろう?

from infra.api.hoge.foo import f, g # <- このg

動的名前解決

f() を部品として使っている g() のパッチが機能している理由。
それは関数gは関数fと同じモジュールで定義されているから。

関数 g が呼び出される時、その時点での関数 f が使用される。
これが動的名前解決という仕組みであり、
おかげで関数 g は置き換え後の f を参照することができる。

f を直接呼ぶのはダメだが g を一枚噛ませると、
from ... でインポートしてもパッチが発動するという一見不思議な状況。

まとめ

monkeypatchで置き換えた関数をfrom ... の形式で直接インポートすると、
パッチが当たらないから気をつけよう。

おまけ

この問題の原因はfromのインポートの仕様が原因だから、
他のmockerのようなパッチツールでも同じことが言えるはず。

Discussion