👏

pytest, mock, patchについて整理したい

2022/11/05に公開

pytest、unittest.mock.patchらへんでハマっていたのでメモ。

整理したかったこと

  • 同じ機能で、書き方が何種類もあること
  • 引数の使い分け

注: このページでは同じものを別の名前で指しているかもしれないです(例:package, module, library という単語は同じものを指すことがある)

テストモジュールについて

  • unittest ... Pythonビルトインのテストpackage
  • pytest ... 3rd partyのテストpackage。unittestよりよく使われていて主流らしい
    • unittestをそのまま使える
    • importしなくても使える(pytest.xxxを使わないとき)

mock について

オブジェクト(何でも)を自由に置き換えるもの

  • unittest.mock ... unittestのモジュール
  • pytest-mock ... pytestでmockerフィクスチャが使える
  • moto ... AWSリソースのためのmock (ここでは扱わない)

参考にしたもの

Python公式のMock
https://docs.python.org/ja/3.10/library/unittest.mock.html

RealPython コードはここから拝借しています(MITライセンス)
https://realpython.com/python-mock-library/

ゼロから学ぶ Python のpytestのページ
https://rinatz.github.io/python-book/ch08-02-pytest/#_7

pytest-mockについてはこちらが丁寧でよかったです
https://webbibouroku.com/Blog/Article/pytest-mock

同じような比較があります。こちらも参考になりました。
https://zenn.dev/re24_1986/articles/0a7895b1429bfa


ディレクトリ

このページでは、慣例とは違いますが以下のように同じディレクトリmy_test/に入れます。すべてこのディレクトリ上で実行しています。

my_test/
├── my_calendar.py
└── test_my_calendar.py

以下に出てくるコードはRealPythonから取ってきたものです。

  • get_holidays()は、ローカルのAPIhttp://localhost/api/holidaysにリクエストする関数。テストでは、このAPIを使わないでいいようにmockする。requestsをインポートして使っているが、これを置き換える。
my_calendar.py
from datetime import datetime

import requests

def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

テスト関数

  • 以下はunittestでのコードで、これをpytest、with文、mockerなどを使って書き換えて比較する
  • my_calendar.pyの中のrequestsモジュールをmock.patchで置き換える(後述)
test_my_calendar.py
import unittest
from unittest.mock import patch

from requests.exceptions import Timeout

from my_calendar import get_holidays


class TestCalendar(unittest.TestCase):
    @patch('my_calendar.requests')
    def test_get_holidays_timeout(self, mock_requests):
        mock_requests.get.side_effect = Timeout
        with self.assertRaises(Timeout):
            get_holidays()
            mock_requests.get.assert_called_once()


if __name__ == '__main__':
    unittest.main()

unittest と pytest

unittestで実行する

python test_my_calendar.py

出力

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

pytestで実行する

クラスのunittest.TestCaseif __name__ == "__main__":は消してしまってもよい。

pytestではstdoutが見えないとか、トレースバックが長過ぎるので、オプションをつけています。

pytest -s --no-header --tb=short test_my_calendar.py

出力

=========...========== test session starts =============
collected 1 item                                                             
test_my_calendar.py .
=========...========== 1 passed in 0.07s =============

mock

「パッチ」と「モンキーパッチ」という言葉は同じ意味で使われる

起源などはwikipediaに書いてある。

MagicMockはMockのサブクラス

実際にはMagicMock が呼び出されている。

違いについては[python] まだmockで消耗してるの?mockを理解するための3つのポイントに詳しく書いてありました。mockにmagicメソッドを追加したものだということでしょうか。

上記のtest_my_calendar.py でprint出力してみると、

    @patch('my_calendar.requests')
    def test_get_holidays_timeout(self, mock_requests):
        mock_requests.get.side_effect = Timeout
        print(mock_requests)
        print(type(mock_requests))

こういうのが出力される。typeはMagicMock。

<MagicMock name='requests' id='140005106486336'>
<class 'unittest.mock.MagicMock'>

mock.patch() と patch()

これはインポートの仕方が違うだけ。たまに混ざっているかも。

from unittest import mock
from unittest.mock import patch

mock、patchの引数

(内容薄め)

from unittest.mock import patchでの
patch.objectでのreturn_valueをnewの違いはAppendixの項目参照

unittest.mock.Mock

class unittest.mock.Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None, name=None, spec_set=None, unsafe=False, **kwargs)

前述のようにMock(MagicMock)のクラスができていて、引数を渡して差し替えることになります。

return_value

モックが呼び出された際に返すを設定

side_effect

モックが呼ばれた際に呼び出される関数、イテラブル、もしくは発生させる例外 (クラスまたはインスタンス) を設定

unittest.mock.patch

unittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)

patchの書き方

    @patch('my_calendar.requests')
    def test_get_holidays_timeout(self, mock_requests):
        mock_requests.get.side_effect = Timeout
        with self.assertRaises(Timeout):
            get_holidays()
            mock_requests.get.assert_called_once()
  • patchするターゲットのパス'my_calendar.requests'をデコレータに渡す
    • この引数は文字列
  • このターゲットはtest関数の中ではmock_requestsという名前で使うと定義
  • side_effectで、mockが呼ばれたときの動作を設定

デコレータとwith 文

書き方が違うが機能は同じ。

test_my_calendar.pyのpatchの部分だけを取り出すとこうなる。

test_my_calendar.py
    # decorator
    @patch('my_calendar.requests')
    def test_get_holidays_timeout(self, mock_requests):
        mock_requests.get.side_effect = Timeout

これをwith を使って書くとこうなる。

   # with
   def test_get_holidays_timeout(self):
       with patch('my_calendar.requests') as mock_requests:
           mock_requests.get.side_effect = Timeout

patch.object

patch.object(target, attribute, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)

オブジェクトの1つのメソッドだけを差し替えたいとき、patch.objectが使える。

import unittest
from my_calendar import requests, get_holidays
from unittest.mock import patch

class TestCalendar(unittest.TestCase):
    @patch.object(requests, 'get', side_effect=requests.exceptions.Timeout)
    def test_get_holidays_timeout(self, mock_requests):
        with self.assertRaises(requests.exceptions.Timeout):
            get_holidays()

if __name__ == '__main__':
    unittest.main()
  • ターゲットのパスではなくて、オブジェクトそのものを第1引数にする。第2引数がmockしたい属性
  • 第1引数が、文字列ではないことに注意(patchとpatch.objectの違い)
  • 例えば、上記で、'requests'すると、エラーになる。
@patch.object('requests', 'get', side_effect=requests.exceptions.Timeout)

E TypeError: 'requests' must be the actual object to be patched, not a str

pytest-mock

pytest-mockを別途インストールすると、mockerフィクスチャが使えます。mockerを使って書くと、少し省略されて短く書けます。

test_my_calender_mocker.py
import pytest
from requests.exceptions import Timeout

from my_calendar import get_holidays


class TestCalendar:
    def test_get_holidays_timeout(self, mocker):
        mocker.patch('my_calendar.requests.get', side_effect=Timeout)
        with pytest.raises(Timeout):
            get_holidays()
            mocker.assert_called_once()

pytest monkeypatch

pytestにデフォルトでついているフィクスチャ機能です。属性、辞書、環境変数を変えたりできます。
https://docs.pytest.org/en/7.1.x/how-to/monkeypatch.html

こちらは上記で同じ書き方をしているタイムアウトのエラーは出せなかったので、正常系の書き方です(できるのかもしれないですが)。MockResponseでMockの返すクラスを定義しておいて、mock_get()で返しています。monkeypatch.setattr(requests, "get", mock_get)とかくと、requestのgetがmock_getに置き換わる、ということになります。

test_my_calender_monkeypatch.py
import requests

import my_calendar


class MockResponse:
    def __init__(self):
        self.status_code = 200

    @staticmethod
    def json():
        return {"status_code": 200}


def test_get_json(monkeypatch):
    def mock_get(*args, **kwargs):
        return MockResponse()

    monkeypatch.setattr(requests, "get", mock_get)
    result = my_calendar.get_holidays()
    assert result["status_code"] == 200

まとめ

  • pytestのやり方を比較してみました。ややこしいですね。
  • 他の人のコードを見るときに、「別の書き方ができる」ということを思い出して注意すれば大丈夫かも?

Appendix

定数をmockしたい

こちらが参考になります。
https://thinkami.hatenablog.com/entry/2019/12/03/232046

ちょっとハマったのでメモ。定数のインポートのやり方に気をつける必要がありそうです。

xxx/my_const.py に 定数が入っているとします。

my_const.py
MY_CONST = 100

テストファイル

from xxx import my_const

@patch.object(my_const, 'MY_CONST', new=1) # MYCONSTは ''必要
def test_my_const(self):
    some_code()

Mockが失敗するがエラーにはならない


from xxx.my_const import MY_CONST
...
print(MY_CONST) # 100。差し替えられていない

Mock成功

from xxx import my_const
...
print(my_const.MY_CONST) # 差し替えOK

ちなみに、この2つの書き方は同じです。

    @patch.object(my_const, 'MY_CONST', new=1)
    @patch('xxx.my_const.MY_CONST', new=1)

メソッドに引数が正しく渡されたかを確認したい

my_mockcall_argsの中身をassertして、mockに渡された引数を確認できます。return=などがあっても同じです。

with patch.object(MYCLASS, 'my_method') as my_mock:
    # some code ...

mock_args = my_mock.call_args[0]
assert mock_args[0] == "arg0"
assert mock_args[1] == "arg1"
...

他の例:
call_args は次のように構成されています:

call_args.args: 非キーワード引数のタプル
call_args.kwargs: キーワード引数の辞書

kwargs = my_mock.call_args.kwargs
assert kwargs.get('some_arg') == xxxxx

patch.objectのreturn_valueとnewの違い

  • patch.object(MyClient, "notify", ...)はMyClientのnotifyを置き換えるという意味
  • return_valueでは関数の実行を飛ばして結果を置き換える。hello from notify()は出力されない
  • newでは関数を置き換える。置き換えた関数は実行される。hello from mock_notify_method()が出力される
from unittest.mock import patch

# モック化するためのサンプルクラスとメソッド
class MyClient:
    def notify(self, message="Original Notify message"):
        print("hello from notify()")
        return message

mock_return_value_object = "Mocked Return Value"

# return_valueを使用して戻り値を変更する例
def example_with_return_value():
    with patch.object(MyClient, "notify", return_value=mock_return_value_object):
        client = MyClient()
        return client.notify()  # "Mocked Return Value"を返す


# newを使用してメソッド自体をmockに置き換える例
def mock_notify_method(self, message="Mocked Notify Method"):
    print("hello from mock_notify_method()")
    return "mock_notify_method: " + message

def example_with_new():
    with patch.object(MyClient, "notify", new=mock_notify_method):
        client = MyClient()
        return client.notify("bbb")  # "mock_notify_method: bbb"を返す

original_message = MyClient().notify()
print(original_message)
print('--')
result_with_return_value = example_with_return_value()
print(result_with_return_value)
print('--')
result_with_new = example_with_new()
print(result_with_new)

#### OUTPUT ####
# hello from notify()
# Original Notify message
# --
# Mocked Return Value
# --
# hello from mock_notify_method()
# mock_notify_method: bbb

エラー:patchのside_effect指定なしだとAttributeError

mocker.patch('my_calendar.requests.get', Timeout)
AttributeError: 'Timeout' object has no attribute 'status_code'

エラー: TestCalendarクラスに入れるとAttributeError

AttributeError: 'TestCalendar' object has no attribute 'patch'

--> selfを入れ忘れでした

class TestCalendar:
    def test_get_holidays_timeout(self, mocker):

Discussion