[Python] pytest でモックを使う方法(pytest-mock)

[Python] pytest でモックを使う方法(pytest-mock)

pytest でモックを利用する

pytest を利用してテストをコーディングする場合にモックを利用したい場合、pytest-mock というライブラリを使うと便利です。
pytest-mockmock パッケージの薄いラッパーを提供します。

この記事では pytest-mock を使ったいろいろなモックの使い方を紹介します。

pytest の基本的な使い方は以下の記事にまとめてます。

[Pytest] pytest 入門、テストコードを書く方法 │ Web備忘録

モックを利用するとテスト用で一時的に処理を差し替えてくれます。モック化した箇所は対象のテスト中においてのみモックとして扱われ、それ以外のテストでは通常の実装内容に戻ってくれます。

モックをうまく使えば機能単位でいい感じにテストすることができます。

pytest-mock のインストール

pytest-mock · PyPI

pip を使ってインストールします。もちろん pytest もインストールする必要があるので一緒に入れておきます。

$ pip install pytest pytest-mock

mocker.patch で任意のオブジェクトをモック化する

pytest-mock を使うと mocker というフィクスチャが提供されるようになります。これを使って任意のオブジェクトについてモック化することが可能です。

例えば以下のようなファイルを削除してその結果を文字列で返すようなメソッドを作成し、これをテストすることを考えます。

import os


# テスト対象のメソッド
def delete_file(file_path):
    try:
        if not os.path.isfile(file_path):
            return 'ファイルが存在しません。'
        
        os.remove(file_path)

        return 'ファイルを削除しました。'
    except:
        return 'エラーが発生しました。'

削除ファイルがないパターン、例外が発生するパターン、削除が完了するパターンの3つの結果があり得ます。これをテストしてみます。

unittest.mock — mock object library — Python 3.9.1 documentation

retrun_value で戻り値を固定にする

実際にファイルを用意してテストしてもよいですが、モックを使って実際にファイルを用意したり削除したりせずにテストしてみます。

mocker.patch を使えば任意のオブジェクトについてモックで処理を差し替えることができます。例えば os.path.isfile をモックにして、常に戻り値を固定にしてファイルが存在しないパターンをテストしてみます。

import os


# テスト対象のメソッド
def delete_file(file_path):
    try:
        if not os.path.isfile(file_path):
            return 'ファイルが存在しません。'
        
        os.remove(file_path)

        return 'ファイルを削除しました。'
    except:
        return 'エラーが発生しました。'


def test_file_not_exist(mocker):
    # os.path.isfile をモック化する(常にFalse)
    mocker.patch('os.path.isfile', return_value=False)

    # テスト対象を実行する
    message = delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'ファイルが存在しません。'

mocker.patch でモック化し、return_value=False としているので os.path.isfile は常に False を返ようなモックになります。もともとの処理は実行されないため存在しないパスを引数に指定しても正常に動作します。

あとはテスト対象を実行し、その中でモックが呼び出されることでテストしたいパターンの結果が得られます。

side_effect で任意の処理に差し替える

上の例ではモックが固定値を返却しますが、任意の処理を実行できるように、指定した関数でモック化することもできます。

import os


# テスト対象のメソッド
def delete_file(file_path):
    try:
        if not os.path.isfile(file_path):
            return 'ファイルが存在しません。'
        
        os.remove(file_path)

        return 'ファイルを削除しました。'
    except:
        return 'エラーが発生しました。'


def test_file_not_exist(mocker):
    # os.path.isfile をモック化(isfile_mock_funcが呼ばれる)
    def isfile_mock_func(file_path):
        return False
    mocker.patch('os.path.isfile', side_effect=isfile_mock_func)

    # テスト対象の実行
    message = delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'ファイルが存在しません。'

もちろん以下のように、ラムダ式を指定してもよいです。

mocker.patch('os.path.isfile', side_effect=lambda file_path: False)

動的な処理に差し替えたい場合は、関数を定義してその中で条件分岐させればよいです。

例外を強制的に送出する

side_effect を使えば、強制的に例外を送出することができます。なのでエラー処理がうまく実装できているかをテストできます。

import os


# テスト対象のメソッド
def delete_file(file_path):
    try:
        if not os.path.isfile(file_path):
            return 'ファイルが存在しません。'
        
        os.remove(file_path)

        return 'ファイルを削除しました。'
    except:
        return 'エラーが発生しました。'


def test_error_pattern(mocker):
    # 例外を送出するモック
    mocker.patch('os.path.isfile', side_effect=Exception)

    # テスト対象の実行
    message = delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'エラーが発生しました。'

上の例では Exception() が送出されます。テスト対象のメソッドが os.path.isfile を呼び出したタイミングで例外が発生しています。例外がキャッチされて、エラーが発生したという内容のメッセージが帰ってくることを検証しています。

モック化した処理に対する操作内容を検証する

mocker.patch でモック化したモックオブジェクトを使うと、処理が何回呼ばれたか、どのような引数で呼ばれたかなどを検証することもできます。

例えば引数の内容と呼び出し回数をテストしたい場合以下のように書けます。

def test_remove(mocker):
    # モック化
    isfile_mock = mocker.patch('os.path.isfile', return_value=True)
    remove_mock = mocker.patch('os.remove')

    # テスト対象の実行
    message = delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'ファイルを削除しました。'

    # モック化した処理が1度だけ正しい引数で呼ばれたか検証
    isfile_mock.assert_called_once_with('test_file_path')
    remove_mock.assert_called_once_with('test_file_path')

mocker.patch の戻り値でモックオブジェクトが取得できるので、テスト対象の実行後にモックに対する操作についての検証を行います。

上の例では2つの処理についてモック化し、それぞれについて "1度だけ呼び出されていること" と "正しい引数で呼び出されていること" という内容の検証を assert_called_once_with というモックオブジェクトのメソッドを使って検証しています。

モックオブジェクトが持つ検証用のメソッドは以下のようなものがあります。

unittest.mock — mock object library — Python 3.10.0a5 documentation

  • assert_any_call: 指定の引数で少なくとも1度呼び出されたか
  • assert_called: 少なくとも1度呼び出されたか
  • assert_called_once: 1度だけ呼びされたか
  • assert_called_once_with: 指定の引数で1度だけ呼びされたか
  • assert_called_with: 最後の呼び出しが指定の引数で呼びされたか
  • assert_has_calls: 指定された呼び出しでモックが呼び出されたか
  • assert_not_called: 呼び出されていないか

大体名前の通りの検証機能を持ちます。キーワード引数についてもテストできます。

def test_sample(mocker):
    makedirs_mock = mocker.patch('os.makedirs')
    os.makedirs('/a/b/c', exist_ok=True)
    makedirs_mock.assert_called_once_with('/a/b/c', exist_ok=True)

それ以外に直接呼び出された引数の値や回数などを参照することも可能です。

  • call_args: 最後に呼び出されたときの引数
  • call_args_list: 呼び出された引数のリスト(呼び出し順)
  • call_count: 呼び出された回数
  • called: 呼び出されたかどうかのフラグ

これらを参照して検証を行うこともできます。

モックオブジェクトの有効範囲

モック化した内容についてはテスト関数内でのみ差し替えが有効になります。あるテストメソッド内でモック化しても別のテストメソッドでは基本影響を受けません。

import os

def test_case_1(mocker):
    mocker.patch('os.makedirs')

def test_case_2(mocker):
    # モックではなく本来の処理が呼び出される
    os.makedirs('./a/b/c', exist_ok=True)

テストメソッド共通でモックを定義したい場合は fixture で定義しましょう。

import os
import pytest

# テストメソッドごとに呼び出されるフィクスチャ
@pytest.fixture(autouse=True)
def init_mock(mocker):
    mocker.patch('os.makedirs')

def test_case_1():
    os.makedirs('./a/b/c', exist_ok=True)
    assert os.makedirs.called

def test_case_2():
    os.makedirs('./a/b/c', exist_ok=True)
    assert os.makedirs.called

mocker.patch がうまく効かないパターン

テスト構成と実装内容

以下のようなディレクトリ構成でテストコードを実装します。

├── src
│   ├── __init__.py
│   └── my_code.py
└── tests
    ├── __init__.py
    └── test_my_code.py

src/my_code.py

from os import remove
from os.path import isfile


# テスト対象のメソッド
def delete_file(file_path):
    try:
        if not isfile(file_path):
            return 'ファイルが存在しません。'
        
        remove(file_path)

        return 'ファイルを削除しました。'
    except:
        return 'エラーが発生しました。'

tests/test_my_code.py

import src.my_code as my_code


def test_remove(mocker):
    # モック化
    isfile_mock = mocker.patch('os.path.isfile', return_value=True)
    remove_mock = mocker.patch('os.remove')

    # テスト対象の実行
    message = my_code.delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'ファイルを削除しました。'

    # モック化した処理が1度だけ正しい引数で呼ばれたか検証
    isfile_mock.assert_called_once_with('test_file_path')
    remove_mock.assert_called_once_with('test_file_path')

実装内容は上のとおりです。基本的にこれまでの内容と変わりはないですが、1点 os モジュールの内容を直接メソッドまで指定してインポートして参照しています。

テストの内容的にはモック化することでファイル削除処理が成功するはずですが、これでテストを実行すると以下のように失敗します。

E       AssertionError: assert 'ファイルが存在しません。' == 'ファイルを削除しました。'
E         - ファイルを削除しました。
E         + ファイルが存在しません。

削除が成功するはずが、ファイルが存在しないとされています。つまり os.path.isfile のモック化がうまく効いていないということです。

失敗する原因と対策

おそらく mocker.patch でモック化される前にすでにインポートしてモジュール内の変数に展開されているせいで、モックオブジェクトで差し替えられていないのが原因です。

なのでテスト対象の実行前に、インポートしたモジュールの isfile, remove をモックで代入して上書きしてやれば想定通り動きますが、そうすると問題があって、ほかのテストに影響してしまいます。

正しく実装方法は os.path.isfile, os.remove をモック化するのではなく、src.my_code.isfile, src.my_code.remove をそれぞれモック化しなければなりません。実装としては以下の通りです。

import src.my_code as my_code


def test_remove(mocker):
    # モック化
    isfile_mock = mocker.patch('src.my_code.isfile', return_value=True)
    remove_mock = mocker.patch('src.my_code.remove')

    # テスト対象の実行
    message = my_code.delete_file('test_file_path')

    # 戻り値のメッセージを検証
    assert message == 'ファイルを削除しました。'

    # モック化した処理が1度だけ正しい引数で呼ばれたか検証
    isfile_mock.assert_called_once_with('test_file_path')
    remove_mock.assert_called_once_with('test_file_path')

Python のモジュールについて正しく理解していなければ迷ってしまいます。モック化したい処理は src.my_code の内容なのでこちらのモジュールの処理をモック化してやることになります。

モックオブジェクトを単に作成する

これまで任意のモジュールのオブジェクトを差し替える形でモックオブジェクトを定義していましたが、単純にモックオブジェクトを作成したい場合があります。例えば以下のようなメソッドをテストする場面などです。

# 引数のメソッドを指定回数実行する
def call(f, count):
    for i in range(count):
        f(i + 1)

引数のメソッドを指定された回数実行するようなメソッドです。f はなんでもよいので適当なモックオブジェクトを渡して指定回数呼び出されるかテストしたいです。以下のよう mocker.Mock() でモックオブジェクトを生成してテストします。

# 引数のメソッドを指定回数実行する
def call(f, count):
    for i in range(count):
        f(i + 1)


def test_call(mocker):
    # モックオブジェクトを作成する
    f_mock = mocker.Mock()

    # テスト対象の処理を実行
    call(f_mock, 3)

    # 検証
    assert f_mock.call_count == 3
    f_mock.assert_any_call(1)
    f_mock.assert_any_call(2)
    f_mock.assert_any_call(3)

モジュールの変数をモック化する

モジュールのメソッドをモック化するだけではなく、変数をモック化することも可能です。単純にモジュール変数に代入すると上書きしてしまいますが、モックを使えばテスト終了後に元の値に戻ります。

src/my_code.py

my_value = 10

def get_my_value():
    return my_value

このようなモジュール変数を返すだけの処理を考えます。この my_value をモックに差し替えます。

tests/test_my_code.py

import src.my_code

def test_get_my_value(mocker):
    # モックでモジュール変数を差し替える
    mocker.patch('src.my_code.my_value', 100)
    assert src.my_code.my_value == 100

def test_get_my_value_2(mocker):
    # こっちでは元の変数の値に戻っているので失敗
    assert src.my_code.my_value == 100

mocker.patch('src.my_code.my_value', 100) のように差し替えたい値を引数で指定するとモックオブジェクトが差し替えてくれます。戻り値はモックオブジェクトではなく指定した値になるので呼び出し回数などは検証できません。ただしテスト終了後に元の値に戻ります。

インスタンスのメンバをモック化する

クラスのインスタンスについて、メソッドをモック化したい場合は mocker.patch.object を使います。

引数にインスタンス変数とモック化したいメンバ名、あとはモックオブジェクトに必要な設定です。return_value, side_effect などを指定します。

以下の例は、100秒くらいかかるとても重たい処理の結果を使った計算をするクラスの処理をテストします。単純にテストすると100秒かかるのでこの部分をモック化しています。

import time

class MyClass:
    def __init__(self) -> None:
        # メンバ変数
        self.wait_sec = 100

    # 重たい処理なのでモック化したい
    def heavy_work(self):
        time.sleep(self.wait_sec)
        return self.wait_sec
    
    # 何か重たい処理結果をいい感じに*2している
    def calc(self):
        x = self.heavy_work()
        return x * 2

def test_my_class(mocker):
    # モック化
    my_class_1 = MyClass()
    mocker.patch.object(my_class_1, 'heavy_work', return_value=10)

    # テスト対象処理の実行
    res = my_class_1.calc()
    assert res == 20

    # 別インスタンスはモック化されない
    # my_class_2 = MyClass()
    # my_class_2.calc()

メソッドをモック化していますが、メンバ変数をモック化することもできます。

メンバ変数をモックで差し替えるには、差し替えたい値を引数で指定します。

import time


class MyClass:
    def __init__(self) -> None:
        # メンバ変数
        self.val = 100

    # 重たい処理なのでモック化したい
    def heavy_work(self):
        time.sleep(self.val)
        return self.val
    
    # 何か重たい処理結果をいい感じに*2している
    def calc(self):
        x = self.heavy_work()
        return x * 2

def test_my_class(mocker):
    # モック化
    my_class_1 = MyClass()
    mocker.patch.object(my_class_1, 'val', 0)

    # テスト対象処理の実行
    res = my_class_1.calc()
    assert res == 0

以上、モックの基本的な使い方でした。

参考URL

Pythonカテゴリの最新記事