2013-04-07

mock.patch() が置き換えする対象

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() 実行時にエラーが出るので、名前がおかしいことに気付くような気がします。