この記事は MicroAd Advent Calendar 2023 の15日目の記事です。
単体テストを書く上で避けて通れないのがモックだと思います。
私自身がモック化(patch
)に苦しめられたのでこの記事を書いています。
Pythonの標準ライブラリunittest
で様々な場面でのpatch
のパターンを紹介します。
例はunittest
で書きますが、pytest
等のテストライブラリであっても同じ考え方でできるはずです。
記事の意義が問われますが公式ドキュメントにだいたい全部書いてあるのでそっちを読みましょう。
パターン集
patch
を使うときに考えるべきは名前空間です。
以下ではimportの方法でパターン分けしています。
関数とメソッド(クラス)の場合で書いていますが、考え方は同じです。
テストコードで使用するコードの基本は以下になります。
sample.py
とtest_sample.py
のtest_main
の内容は都度変わります。
from unittest import TestCase
from unittest.mock import patch
import sample
class TestSample(TestCase):
def test_main(self):
sample.main()
import hoge
def main():
hoge.fuga()
def fuga():
print("hello world")
class Fuga:
def piyo(self):
print("hello world")
このまま実行すると以下のようになり、fuga
関数の中のprint
が実行されていることがわかります。
-> % python -m unittest discover tests/
hello world
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
import hoge
import hoge
の場合はhoge
がローカルの名前空間上に束縛され、実際にhoge.fuga
を使うときにはsample.hoge.fuga
とhoge.fuga
を経由して「本物のfuga
」を参照するので、経由するどちらかにパッチをあてればいいです。
関数の場合
このときのsample.py
の内容は上と同じです。
以下でfuga
をモック化できます。
@patch('hoge.fuga')
def test_main(self, mock):
sample.main()
printされておらず、モック化できたことがわかります。
-> % python -m unittest discover tests/
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
以下でもモック化が可能です。
@patch('sample.hoge.fuga')
メソッド(クラス)の場合
import hoge
def main():
hoge.Fuga().fuga()
以下のどちらかでfuga
をモック化できます。
@patch('hoge.Fuga.fuga')
@patch('sample.hoge.Fuga.fuga')
クラスごとモック化もできます。
@patch('hoge.Fuga')
@patch('sample.hoge.Fuga')
import hoge as piyo
import hoge as piyo
の場合はpiyo
がローカルの名前空間上に束縛され、実際にpiyo.fuga
を使うときにはsample.piyo.fuga
とhoge.fuga
を経由して「本物のfuga
」を参照するので、経由するどちらかにパッチをあてればいいです。
関数の場合
import hoge as piyo
def main():
piyo.fuga()
以下のどちらかでfuga
をモック化できます。
@patch('hoge.fuga')
@patch('sample.piyo.fuga')
メソッド(クラス)の場合
import hoge as piyo
def main():
piyo.Fuga().fuga()
以下のどちらかでfuga
をモック化できます。
@patch('hoge.Fuga.fuga')
@patch('sample.piyo.Fuga.fuga')
クラスごとモック化もできます。
@patch('hoge.Fuga')
@patch('sample.piyo.Fuga')
from hoge import fuga
from hoge import fuga
の場合はfuga
がローカルの名前空間上に束縛され、実際にfuga
を使うときにはsample.fuga
を経由して「本物のfuga
」を参照するので、そこにパッチをあてればいいです。
このとき、import
パターンで経由していたhoge.fuga
はないので、そこに対してパッチをあててもうまくいきません。
関数の場合
from hoge import fuga
def main():
fuga()
以下でfuga
をモック化できます。
@patch('sample.fuga')
メソッド(クラス)の場合
from hoge import Fuga
def main():
Fuga().fuga()
以下でfuga
をモック化できます。
@patch('sample.Fuga.fuga')
クラスごとモック化もできます。
@patch('sample.Fuga')
from hoge import fuga as piyo
from hoge import fuga as piyo
の場合はpiyo
がローカルの名前空間上に束縛され、実際にpiyo
を使うときにはsample.piyo
を経由して「本物のfuga
」を参照するので、そこにパッチをあてればいいです。
このときもhoge.fuga
を経由しないので、そこに対してパッチをあててもうまくいきません。
関数の場合
from hoge import fuga as piyo
def main():
piyo()
以下でfuga
をモック化できます。
@patch('sample.piyo')
メソッド(クラス)の場合
from hoge import Fuga as Piyo
def main():
Piyo().fuga()
以下でfuga
をモック化できます。
@patch('sample.Piyo.fuga')
クラスごとモック化もできます。
@patch('sample.Piyo')
組み込み関数・型
組み込み関数・型(仮にprint
とします)の場合はprint
がローカルの名前空間上に束縛され、実際にprint
を使うときにはsample.print
とbuiltins.print
を経由して「本物のprint
」を参照するので、経由するどちらかにパッチをあてればいいです。
関数の場合
def main():
print("hello world")
以下のどちらかでprint
をモック化できます。
@patch('sample.print')
@patch('builtins.print')
型の場合
def main():
print(list("hello world"))
以下のどちらかでlist
をモック化できます。
@patch('sample.list')
@patch('builtins.list')
おまけ
cannot set 'fuga' attribute of immutable type 'hoge'
たとえばdatetime.now
をモック化して常に同じ時刻を返したい、みたいな場面があったとします。
freezegun
等の専用のライブラリもありますが、ここでは使わない場合を考えます。
ちなみにこれも公式ドキュメントにあります。
from datetime import datetime
def main():
print(datetime.now())
ストレートに行くと以下でモック化しようとするでしょう。
しかしこれだとエラーが発生します。
@patch('sample.datetime.now')
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'
解決策としてdatetime
自体をモック化する方法が出てきます。
@patch('sample.datetime')
def test_main(self, mock):
from datetime import datetime
mock.now.return_value = datetime(2023, 1, 2, 3)
sample.main()
これはクラス全体をモック化してしまい、以下のようにnow
以外も使っている場面ではdatetime()
の呼び出しもモック化されてしまい不適切です。
from datetime import datetime
def main():
print(datetime.now())
print(datetime(2023, 12, 15, 7))
-> % python -m unittest discover tests/
2023-01-02 03:00:00
<MagicMock name='datetime()' id='4380121248'>
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
使用する全てのメソッドの返す値を記述する、もしくは以下の方法で解消できます。
mock.side_effect = lambda *args, **kw: datetime(*args, **kw)