7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MicroAd (マイクロアド) Advent Calendar 2023

Day 15

Pythonのunittest.mock.patchパターン集(+おまけ)

Last updated at Posted at 2023-12-14

この記事は MicroAd Advent Calendar 2023 の15日目の記事です。

単体テストを書く上で避けて通れないのがモックだと思います。
私自身がモック化(patch)に苦しめられたのでこの記事を書いています。

Pythonの標準ライブラリunittestで様々な場面でのpatchのパターンを紹介します。
例はunittestで書きますが、pytest等のテストライブラリであっても同じ考え方でできるはずです。
記事の意義が問われますが公式ドキュメントにだいたい全部書いてあるのでそっちを読みましょう。

パターン集

patchを使うときに考えるべきは名前空間です。
以下ではimportの方法でパターン分けしています。
関数とメソッド(クラス)の場合で書いていますが、考え方は同じです。

テストコードで使用するコードの基本は以下になります。
sample.pytest_sample.pytest_mainの内容は都度変わります。

test_sample.py
from unittest import TestCase
from unittest.mock import patch
import sample

class TestSample(TestCase):
    def test_main(self):
        sample.main()
sample.py
import hoge

def main():
    hoge.fuga()
hoge.py
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.fugahoge.fugaを経由して「本物のfuga」を参照するので、経由するどちらかにパッチをあてればいいです。

関数の場合

このときのsample.pyの内容は上と同じです。
以下でfugaをモック化できます。

test_sample.py
    @patch('hoge.fuga')
    def test_main(self, mock):
        sample.main()

printされておらず、モック化できたことがわかります。

-> % python -m unittest discover tests/ 
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

以下でもモック化が可能です。

test_sample.py
    @patch('sample.hoge.fuga')

メソッド(クラス)の場合

sample.py
import hoge

def main():
    hoge.Fuga().fuga()

以下のどちらかでfugaをモック化できます。

test_sample.py
    @patch('hoge.Fuga.fuga')
test_sample.py
    @patch('sample.hoge.Fuga.fuga')

クラスごとモック化もできます。

test_sample.py
    @patch('hoge.Fuga')
test_sample.py
    @patch('sample.hoge.Fuga')

import hoge as piyo

import hoge as piyoの場合はpiyoがローカルの名前空間上に束縛され、実際にpiyo.fugaを使うときにはsample.piyo.fugahoge.fugaを経由して「本物のfuga」を参照するので、経由するどちらかにパッチをあてればいいです。

関数の場合

sample.py
import hoge as piyo

def main():
    piyo.fuga()

以下のどちらかでfugaをモック化できます。

test_sample.py
    @patch('hoge.fuga')
test_sample.py
    @patch('sample.piyo.fuga')

メソッド(クラス)の場合

sample.py
import hoge as piyo

def main():
    piyo.Fuga().fuga()

以下のどちらかでfugaをモック化できます。

test_sample.py
    @patch('hoge.Fuga.fuga')
test_sample.py
    @patch('sample.piyo.Fuga.fuga')

クラスごとモック化もできます。

test_sample.py
    @patch('hoge.Fuga')
test_sample.py
    @patch('sample.piyo.Fuga')

from hoge import fuga

from hoge import fugaの場合はfugaがローカルの名前空間上に束縛され、実際にfugaを使うときにはsample.fugaを経由して「本物のfuga」を参照するので、そこにパッチをあてればいいです。
このとき、importパターンで経由していたhoge.fugaはないので、そこに対してパッチをあててもうまくいきません。

関数の場合

sample.py
from hoge import fuga

def main():
    fuga()

以下でfugaをモック化できます。

test_sample.py
    @patch('sample.fuga')

メソッド(クラス)の場合

sample.py
from hoge import Fuga

def main():
    Fuga().fuga()

以下でfugaをモック化できます。

test_sample.py
    @patch('sample.Fuga.fuga')

クラスごとモック化もできます。

test_sample.py
    @patch('sample.Fuga')

from hoge import fuga as piyo

from hoge import fuga as piyoの場合はpiyoがローカルの名前空間上に束縛され、実際にpiyoを使うときにはsample.piyoを経由して「本物のfuga」を参照するので、そこにパッチをあてればいいです。
このときもhoge.fugaを経由しないので、そこに対してパッチをあててもうまくいきません。

関数の場合

sample.py
from hoge import fuga as piyo

def main():
    piyo()

以下でfugaをモック化できます。

test_sample.py
    @patch('sample.piyo')

メソッド(クラス)の場合

sample.py
from hoge import Fuga as Piyo

def main():
    Piyo().fuga()

以下でfugaをモック化できます。

test_sample.py
    @patch('sample.Piyo.fuga')

クラスごとモック化もできます。

test_sample.py
    @patch('sample.Piyo')

組み込み関数・型

組み込み関数・型(仮にprintとします)の場合はprintがローカルの名前空間上に束縛され、実際にprintを使うときにはsample.printbuiltins.printを経由して「本物のprint」を参照するので、経由するどちらかにパッチをあてればいいです。

関数の場合

sample.py
def main():
    print("hello world")

以下のどちらかでprintをモック化できます。

test_sample.py
    @patch('sample.print')
test_sample.py
    @patch('builtins.print')

型の場合

sample.py
def main():
    print(list("hello world"))

以下のどちらかでlistをモック化できます。

test_sample.py
    @patch('sample.list')
test_sample.py
    @patch('builtins.list')

おまけ

cannot set 'fuga' attribute of immutable type 'hoge'

たとえばdatetime.nowをモック化して常に同じ時刻を返したい、みたいな場面があったとします。
freezegun等の専用のライブラリもありますが、ここでは使わない場合を考えます。
ちなみにこれも公式ドキュメントにあります。

sample.py
from datetime import datetime

def main():
    print(datetime.now())

ストレートに行くと以下でモック化しようとするでしょう。
しかしこれだとエラーが発生します。

test_sample.py
    @patch('sample.datetime.now')
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

解決策としてdatetime自体をモック化する方法が出てきます。

test_sample.py
    @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()の呼び出しもモック化されてしまい不適切です。

sample.py
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

使用する全てのメソッドの返す値を記述する、もしくは以下の方法で解消できます。

test_sample.py
        mock.side_effect = lambda *args, **kw: datetime(*args, **kw)
7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?