mock.patch() の挙動について質問されて、即答できなかったので、簡単に調べ直してみました。
簡単なサンプル
まず以下のようなコードを想定します。
# foo.py その1 import random def pickup(seq): return random.choice(seq)
random.choice() 関数は呼び出すごとに挙動が変わり、テストしにくいので、mock の出番です。
# test.py from unittest.mock import patch import foo with patch('random.choice') as m: m.return_value = 0 assert foo.pickup([1, 2, 3]) == 0
import の仕方を変えるとモックに失敗する
ここで foo.py の書き方を以下のように変えます。
# foo.py その2 from random import choice def pickup(seq): return choice(seq)
pickup() 関数を外側から見た挙動は同じです。しかし、なんということでしょう、test.py を実行すると assert が失敗します。pdb や print を使って調べるとわかりますが、choice() 関数が Mock オブジェクトになっていません。
名前に対してパッチしている
with patch('random.choice') のコンテクストに入るとき、大雑把に以下のようなことが起こります。
import random random.choice = Mock()
※ 実際には Mock ではなく、そのサブクラス MagicMock ですが、この議論の本質ではないので、Mock で話を進めます。
Python のプロセスでは、モジュールはシングルトンとしてふるまうため、プロセス内で random.choice という名前は、Mock オブジェクトを参照することになります。foo.py その1の中では random.choice という名前を使っているため、Mock オブジェクトを参照します。
ところが foo.py その2 は random.choice という名前を参照しているわけではありません。from random import choice すると、大雑把に以下のようなことが起こります。
import random choice = random.choice del random
patch() 適用後、名前の参照先は
- グローバルな random.choice => Mock オブジェクト
- foo.py の choice => 元の choice 関数
のようになっています。このため、foo.py その2では choice がモックにならないのです。
名前空間 foo 以下の名前に対して patch を適用する
じゃあ、どうするのかというと、普段は patch('foo.patch') としています。上記の問題が発生するような状況だというのが分かっていれば、これで解決です。しかしながら、foo.py を、その1からその2に実装を変えたとき、テストが fail するけれど、どこがどうなっているのか見つけにくいな、と思いました。
その1のときに、test.py に patch('foo.random.choice') と書くとよいのでしょうか。こうすると foo.py その2に書き換えてテストすると、fail ではなくて patch() 実行時にエラーが出るので、名前がおかしいことに気付くような気がします。