[python] まだmockで消耗してるの?mockを理解するための3つのポイント

イミュータブルなオブジェクトをパッチしたい

Pythonには書き換え不可能なオブジェクトが存在します。
テスト対象として一番よく遭遇するのはdatetime(またはdate)でしょう。

今回は「datetime.now()」が返却する日時を固定化させたいというシナリオです。

>>> from datetime import datetime
>>> # datetimeオブジェクトは変更できない
>>> with mock.patch('__main__.datetime.now', return_value=datetime(2015, 1, 1)):
...     datetime.now()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/site-packages/mock/mock.py", line 1460, in __enter__
    setattr(self.target, self.attribute, new_attr)
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
>>> # datetimeオブジェクト自体をMockオブジェクトと差し替えた上で、それぞれのオブジェクトを入れていく
>>> with mock.patch('__main__.datetime', **{'now.return_value': datetime(2015, 1, 1)}):
...     datetime.now()
datetime.datetime(2015, 1, 1, 0, 0)

さて、お気づきの通りこのやり方には少々欠点があります。ほかの属性が巻き込まれるのです。
datetime.now以外にdatetime.strptimeも同じ関数内で使っているなんてよくあることですよね。
datetimeを差し替えるとdatetime.stftimeもMockオブジェクトを返却するようになってしまいます。

現実的な解決方法は使われている属性をすべて定義してしまうことです。

>>> with mock.patch('__main__.datetime', **{
...     'now.return_value': datetime(1, 1, 1),
...     'strptime.return_value': datetime(1, 2, 3),
... }):
...     datetime.now()
...     datetime.strptime(2000, '%Y')
datetime.datetime(1, 1, 1, 0, 0)
datetime.datetime(1, 2, 3, 0, 0)

また、今回のようにdatetime.now, date.todayを固定化するだけであればtestfixturesというライブラリを使うのが理想的です。

$ pip install testfixtures

複数のオブジェクトをパッチしたい

単純にパッチしたいオブジェクトの分だけ繰り返せばよいのですが、記述方法については多少コツのようなものがあります。

withをネストする

date.todayとdatetime.now()を同時に書き換えてみましょう。

Python2だとnested関数を使うのが一般的です
contextlib.nestedは現在推奨されていないらしいです。
thanks tell-k

>>> from contextlib import nested
>>> from datetime import date, datetime
>>>
>>> with nested(
...     mock.patch('__main__.datetime'),
...     mock.patch('__main__.date')
... ) as (m, n):
...     print(m, n)
(<MagicMock name='datetime' id='7696572181200'>, <MagicMock name='date' id='7696572558416'>)

Python2.7以降(Python 3)ではこのように書く。

>>> from unittest import mock
>>> from datetime import date, datetime
>>> with mock.patch('__main__.datetime') as m, mock.patch('__main__.date') as n:
...     print(m, n)
<MagicMock name='datetime' id='358415501576'> <MagicMock name='date' id='358416373912'>
 
>>> # そもそもnestedがない!
>>> from contextlib import nested
Traceback (most recent call last):
  File "<pyshell#7>", line 1, in <module>
    from contextlib import nested
ImportError: cannot import name 'nested'

その他、3.3からはcontextlib.ExitStackなるものがあるらしいので気になる方は触ってみるといいのではないでしょうか
あつおさんの記事

デコレータをネストする

patchを関数に対してかけたい場合は、デコレータとして利用します。
パッチされたMockオブジェクトが引数として渡されるのですが、重要なのはその順番です。

>>> from datetime import date, datetime
>>> @mock.patch('__main__.date')
... @mock.patch('__main__.datetime')
... def test_patch(mock_datetime, mock_date):
...     print(mock_datetime, mock_date)
>>> test_patch()
(<MagicMock name='datetime' id='7696571704656'>, <MagicMock name='date' id='7696571734288'>)

withが書いた順に渡されるのに対し、デコレータでは内側(深いほう)が先に渡されます。
これは内側のデコレータから順に対象関数を包んでいくからです。

with …() as xにMockオブジェクトを差し込みたい

「with open(‘test.txt’) as f」としている箇所で「test.txt」の内容を書き換えたいことがあるかもしれません。
その場合は以下のように対処できます。

>>> with mock.patch.object(__builtins__, 'open') as m:
...     m.return_value.__enter__.return_value.read.return_value = 'abc'
...     with open('test.txt') as f:
...         print(f.read())
abc

ポイントは__enter__メソッドの動作を書き換えることです。
withコンテキストでは__enter__の返却値がasにわたるため、そのあとは使いそうな属性を書き換えていけばよいのです。

contextlibを使うと以下のようにも書けます。どちらがわかりやすいかは個人差がありそう。

>>> @contextlib.contextmanager
... def dummy_open(path):
...     yield mock.MagicMock(**{'read.return_value': 'abc'})
...
>>> with mock.patch.object(__builtins__, 'open', side_effect=dummy_open):
...     with open('test.txt') as f:
...         print(f.read())
abc

openに関してはmock_openというのがあり、fileオブジェクトとして扱われるようです。

おまけ

>>> # name属性はmockの名前となる(ほとんど使わないけど)
>>> m = mock.MagicMock(name='test')
>>> m
<MagicMock name='test' id='7696573686952'>

くぅ~、疲れましたwこれにて完結です
あとはリファレンス読んでください!

その他、参考にした情報ソース

おかしいところがあったら優しくツッコみください

1 2