👏

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

2022/11/05に公開約7,700字

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の引数

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

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

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

return_value

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

side_effect

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

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

エラー: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

ログインするとコメントできます