🦔

【pytest】モックの使い方まとめ

2022/05/15に公開約9,200字

はじめに

下記のような処理の場合、そのまま実行させてテストするのが難しかったりします。

  • 外部システムへアクセスする
    ⇒ 外部APIやスクレイピングしてたりする場合
  • ライブラリに依存する処理
    ⇒ ライブラリの処理結果ごとのテストをしたい(戻り値、例外の種類での分岐など)
  • テスト実行の度に返す値が異なる処理を利用する
    ⇒ 現在日時、ランダム値は、実行の度に変化するのでテストの時だけ固定値にしたい

こういう場合、モックを使うと処理を差し替えられるので便利です。

使い方

前提

pytestpytest-mockのライブラリを利用します。
pytest-mockunittest.mockのラッパーです。書き方は異なるが、だいたいunittest.mockと同じような感じです。

with使ってpatchする方法も検索すると出てきたが、1つのテストでここまでは実際の動きで、ここからはモックで動かす状況というのが個人的には想像できなかったので、モックのスコープがテストケースごとになるように書いてます。

基本

main.py
def func_main():
    return func_hoge()

def func_hoge():
    return [x for x in range(10)]

func_hoge()をモックにして、func_main()をテストする

test_main.py
def test_func_main(mocker):
    mocker.patch("main.func_hoge", return_value=[1, 2])
    print(main.func_main()) # [1, 2]

pytest-mockをインストールすれば、fixtureでmockerが使えるようになります。
差し替えたい処理をpatchで指定してモックにします。

上記例では、戻り値としてreturn_valueを指定しています。戻り値がない場合は、指定しなくてもいいです。

注意:正しい名前空間で指定

import ~ from ~ import ~でインポートした場合とで、指定方法が異なります。

(1)main.py
import os
def func_main():
    return os.getcwd()
(2)main.py
from os import getcwd
def func_main():
    return getcwd()

getcwdを差し替えるために指定するパスが変わります。

test_main.py
def test_func_main(mocker):
    mocker.patch("os.getcwd", return_value="/var/app") # (1)の場合
    mocker.patch("main.getcwd", return_value="/var/app") # (2)の場合
    print(main.func_main()) # /var/app

で動作します

ここら辺の細かい説明は

https://qiita.com/Chanmoro/items/69f401ddbe41e818a8cf

を参照

関数/各種メソッド/プロパティの置き換え

main.py
import os


def func_main():
    s = Sample()
    print("")
    print("ライブラリ呼び出し:" + os.getcwd())
    print("関数呼び出し:" + str(func_sample()))
    print("インスタンスメソッド呼び出し:" + str(s.half_value()))
    print("プロパティ呼び出し:" + str(s.value))
    print("クラスメソッド呼び出し:" + str(Sample.get_cvalue()))
    print("スタティックメソッド呼び出し:" + str(Sample.get_svalue()))
    print("")


def func_sample():
    return 0


class Sample:
    __cvalue = 30

    def __init__(self):
        self.__value = 20

    def half_value(self):
        return int(self.value / 2)

    @property
    def value(self):
        return self.__value

    @classmethod
    def get_cvalue(cls):
        return cls.__cvalue

    @staticmethod
    def get_svalue():
        return 40
test_main.py
import main


def test_func_main(mocker):
    m1 = mocker.patch("os.getcwd", return_value="/var/app")
    m2 = mocker.patch("main.func_sample", return_value=1)
    m3 = mocker.patch("main.Sample.half_value", return_value=11)
    m4 = mocker.patch("main.Sample.value", return_value=21, new_callable=mocker.PropertyMock)
    m5 = mocker.patch("main.Sample.get_cvalue", return_value=31)
    m6 = mocker.patch("main.Sample.get_svalue", return_value=41)
    main.func_main()

patchする方法としては、関数でもメソッドでも同じです。
プロパティに関してはnew_callable=mocker.PropertyMockを指定する必要があります。

後述するが、patchの戻り値を利用すれと呼び出した回数/引数の検証などができる。

ダミークラスへ差し替え

各メソッドを個別に設定してもいいが、テスト共通で同じ挙動に差し替える場合とかであれば、ダミークラスを定義して差し替えてもよい

test_main.py
class DummyClass:
  # 省略
  pass
  
mocker.patch("main.Sample", new=DummyClass)

メソッドの呼び出しが正常に通過すればよいというだけであれば、何も指定しなくてもよい。

test_main.py
mocker.patch("main.Sample")

戻り値を設定する

常に同じ値

test_main.py
mocker.patch("main.func_sample", return_value=1)

実行回数によって変える

test_main.py
mocker.patch("main.func_sample", side_effect=[10, 20, 30])

何回目の実行かで、返す値を変えています。
1回目実行=10、2回目実行=20、3回目実行=30を返すように設定しています。

引数の値によって変える

test_main.py
mocker.patch("main.func_sample", side_effect=lambda x: False if x % 2 else True)

side_effectは関数やラムダ式も指定できるので。引数に受け取った値で返す値を変えることもできます。

上記は、引数=1ならFalse、引数=2ならTrue、引数=3ならFalseを返すように設定しています。

例外を発生させる

test_main.py
mocker.patch("main.func_sample", side_effect=Exception("new exception"))

mockの実行結果の検証(実行回数)

test_main.py
import main

def test_func_main(mocker):
    m = mocker.patch("main.func_sample", return_value=1)
    main.func_main()

    assert m.call_count == 3 # call_countで呼ばれた回数を取得
    m.assert_called()        # 少なくても1度は呼ばれた事の検証
    m.assert_called_once()   # 1回だけ呼ばれた事の検証
    m.assert_not_called()    # 1回も呼ばれていない事の検証

mockの実行結果の検証(引数)

test_main.py
import main

def test_func_main(mocker):
    m = mocker.patch("main.func_sample", return_value=1)
    main.func_main()

    m.assert_called_with(1, 2, 3)        # 最後に実行したモックの引数を検証
    m.assert_called_with(1, 2, x=3, y=4) # 引数で位置、キーワード混在の場合
    m.assert_called_once_with(1)         # 指定した引数で一度だけ呼ばれたか検証
    m.assert_any_call(2)                 # 指定した引数で一度でも呼ばれたか検証
    
    # 複数回実行時に、それぞれの引数で順番に実行したか検証
    # 1回目実行:引数=1, 2回目実行:引数=2, 3回目実行:引数=3を受け取っているか?
    # ※ 順番に引数をチェックしているだけなので、実行回数はチェックしていない
    m.assert_has_calls(
        [mocker.call(1),
         mocker.call(2),
         mocker.call(3)]
    )
    # any_order=Trueを指定すると、順番を問わずとなる
    # 指定した引数で呼び出したことがあるかを検証する
    m.assert_has_calls(
        [mocker.call(1),
         mocker.call(2),
         mocker.call(3)],
	 any_order=True
    )
    
    # 最後に実行した引数を取得(位置引数、キーワード引数それぞれのタプルで取得)
    args, kwargs = m.call_args
    # 実行した順に引数リストを取得
    args_list = m.call_args_list
    for args, kwargs in args_list:
        pass

mocker.patchとmocker.patch.object の違い

https://note.crohaco.net/2015/python-mock/#mock.patch.object

詳しくは↑を参照。
基本的に、mocker.patchを使ってれば事足りる気がする。

辞書の差し替え

mocker.patch.dictを使う

main.py
d = {"a": 10, "b": 20, "c": 30}

def func_main():
    print(d)

test_main.py
import main


def test_func_main(mocker):
    # 一部上書き
    mocker.patch.dict("main.d", {"c": 40})
    main.func_main() #  {"a": 10, "b": 20, "c": 40}
    
    # 既存データをすべてクリアする場合
    mocker.patch.dict("main.d", {"c": 40}, clear=True)
    main.func_main() #  {"c": 40}

環境変数の差し替え

mocker.patch.dictを使って同じように、環境変数をパッチできる。

import os

def func_main():
    return os.environ

def test_func_main(mocker):
    mocker.patch.dict('os.environ', {'NEW_KEY': 'newvalue'})
    ret = func_main()
    assert ret["NEW_KEY"] == "newvalue"

日時の差し替え

日時取得しているような処理をテストしようとして、上記までと同じようにdatetime.datetime.nowpatchしようとすると…
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'とエラーが出てpatchできないので、テストする方法をいろいろ調べた。

[1] freezegunを使う

pytest-freezegunをインストールして、下記のように書けば日時を固定化できます。

import datetime
import pytest

def func_main():
    return datetime.datetime.now()

@pytest.mark.freeze_time(datetime.datetime(2000, 1, 2, 3, 4))
def test_func_main(mocker):
    assert func_main() == datetime.datetime(2000, 1, 2, 3, 4)

[2] datetimeオブジェクト自体を差し替え、各オブジェクトを設定していく

import datetime

def func_main():
    return datetime.datetime.now()

def test_func_main(mocker):
    expect = datetime.datetime(2000, 1, 2, 3, 4, 5, 6)  # 差し替える前に予測値を取得しとく
    mocker.patch("datetime.datetime", **{
        "now.return_value": datetime.datetime(2000, 1, 2, 3, 4, 5, 6)
    })
    assert func_main() == expect

https://note.crohaco.net/2015/python-mock/#イミュータブルなオブジェクトをパッチしたい

[3] 日時返却用のラッパー関数を作成する

[1][2]でもいいのだが、これだけのためにpytest-freezegunを入れたくない、だからと言って[2]のような記述をいろんなところで書きたくない…

ってことで、datetimeオブジェクトを返すだけのラッパー関数を作成して使うようにするという方法もある。

main.py
import datetime

def get_now():
    return datetime.datetime.now()

def func_main():
    return get_now()
test_main.py
import main
import datetime

def test_func_main(mocker):
    mocker.patch("main.get_now", return_value=datetime.datetime(2000, 1, 2, 3, 4, 5, 6))
    assert main.func_main() == datetime.datetime(2000, 1, 2, 3, 4, 5, 6)

現在日付を取得する処理は、常にget_nowを使うようにする。そうすると、他の関数と同様の記述レベルでpatchができるようになる。

get_nowのテストケースだけ、[2]とかの方法で書けばいいと思う。

requestsライブラリのmock化

テストするたびに外部のサイトへバチバチアクセスしたら、迷惑すぎるので通信部分をモック化してみます。

main.py
import requests

def func_main(url):
    res = requests.get(url)
    res.raise_for_status()

    return res.text

単純にリクエストして、レスポンスを返すだけの関数です。requests.get()をモック用のレスポンスを返す関数を作成して差し替えます。

test_main.py
import main

def mocked_requests_get(*args, **kwargs):
    class MockResponse:
        def __init__(self, text, status_code):
            self.__text = text
            self.__status_code = status_code

        @property
        def text(self):
            return self.__text

        def raise_for_status(self):
            if self.__status_code != 200:
                raise Exception("requests error")

    if args[0] == 'url1':
        return MockResponse("Hello World!", 200)
    elif args[0] == 'url2':
        return MockResponse("goodbye World!", 200)

    return MockResponse(None, 404)

def test_func_main(mocker):

    mocker.patch("requests.get", side_effect=mocked_requests_get)
    print(main.func_main("url1"))  # Hello World!
    print(main.func_main("url2"))  # goodbye World!
    print(main.func_main(""))      # 例外が発生

必要に応じて、使うメソッドをモックレスポンスに追加していけばいいと思います。

実際作る時は、通信部分をラップするクラスなどを作成して使うようにしたほうがテストが複雑にならずに済むのでいいと思う。

参考:

https://dev.classmethod.jp/articles/python_unittest_mock/

おわりに

日時の差し替え方法だけ調べてたはずが、なんやかんやあっていろいろ調べた…

ライブラリのモック化については、複雑なテストケースになりそうだったら使いやすくラップしたものを作ることも検討したほうがよさそうです。

間違いとかあったら教えてくださいm(_ _"m)

Discussion

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