この記事は 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)