【Python】 pytest におけるモックの扱いまとめ

Python Python
Python

pytest について

みなさん、pytest で快適な単体テストライフを送っていますでしょうか。

私は単体テストが嫌いです。理由は品質担保が目的とは言え、生産性が(目にみえて)ないから…。まあちゃんとやるんですけどね。

最近、Pythonでサーバサイドを構築する機会があり、改めてpytestを使った単体テストを実施していたので、その時得たモックを使ったテストに関する知見を残そうと思います。

 

pytest とは

pytestとは、その名の通りPythonで書かれたコードをテストするフレームワークになります。

公式では下記のように紹介されています。

The pytest framework makes it easy to write small, readable tests, and can scale to support complex functional testing for applications and libraries.

pytestは、アプリケーションやライブラリの複雑な関数のテストを簡単に、読みやすいく、書くことができるフレームワークです。

引用元: pytest

pytestはとにかく使いやすい、わかりやすいツールだと思います。

今回はそのpytestを使った単体テストにおいてモックを使ったテストを実施するを時のやり方についてご紹介します。

 

モックとは

モックについては別の記事で紹介します。⇨<>

pytest準備

pytestのインストール

お手持ちのCLI環境(Python, pip環境構築済み)で下記のコマンドを実行し、pytest, pytest-mockライブラリをインストールします。

$ pip install pytest
$ pip install pytest-mock

僕がPythonでアプリケーション開発する際は、Pythonの仮想環境の一つであるvenvを用いてテスト用の環境を構築しています。

pytestライブラリはpytestフレームワークのコアライブラリになるので、pytestを実施する際は必須になるのですが、pytest-mockは任意です。

今回はモックを使ったテストを実施するため、pytestでモックを扱えるpytest-mockをインストールしています。

 

テスト対象のコードの準備

今回は模擬的に下記のような簡易的なテスト対象コードを準備しました。
ファイルはsample.pyとします。

# Test Sample 1
# 足し算の結果を表示させる
def add(a, b):
    if isinstance(a, int) and isinstance(b, int):
        return a + b
    else:
        raise ValueError

def add_print(a, b): 
    try:
        print(str(a) + '+' + str(b) + 'は' + str(add(a, b)) + 'だよ')
    except ValueError:
        print('INPUTの値が正しくありません')

# Test Sample 2
# ランダム数を表示させる

import random
def random_int(range):
    if isinstance(range, int):
        return random.randrange(range)
    else:
        raise ValueError

def random_print(range):
    try:
        print('ランダム数は' + random_int(range) + 'だよ')
    except ValueError:
        print('INPUTの値が正しくありません')
    

テストサンプル1におけるadd関数は、単純に引数でもらった二つの数字(Int型でなければエラーを発生させる)を加算して返却します。

add_print関数はadd関数を呼び出して、add関数の結果をコンソール表示する関数になります。

一方で、テストサンプル2についてはrandom_int関数で引数の値(Int型でなければエラーを発生させる)範囲内の整数をランダムで返す関数になり、random_print関数ではrandom_int関数の結果を表示させる処理を行なっている。

この2つのテストサンプルのそれぞれの関係性は依存関係になっています。

[func: add_print]   <–呼び出し—   [func: add]

[func: random_print]   <–呼び出し—   [func: random_int]

 

pytestによる単体テスト

今回の単体テストでは一般的なC1(ブランチ・カバレッジ)を担保することを目指します。

C1(ブランチ・カバレッジ)とは別名「分岐網羅」。
テスト対象の関数における分岐を全て網羅することで対象コードの全実行結果に対するアウトプットの担保を取る考え方。

 

テストサンプル1

さて、まずは簡単にpytestのおさらいとして、先ほどの簡単な例であるテストサンプル1に対して単体テストを実施していきましょう。

早速、単体テスト用コードを書いていきます。
ファイルはpytestのお約束に則ってtest_sample1.pyとします。

from sample.py import add_print

# Test Sample 1 Test Code
# 正常系テスト

def test_sample_1_normal():
    テスト対象に注入するINPUT
    a = 1
    b = 2
    # 出力結果の予想
    expect = '1+2は3だよ'
    # 実際の出力結果
    result = add_print(a, b)
    assert result == expect

#異常系テスト

def test_sample_1_abnormal():
    # テスト対象に注入するINPUT(異常系試験のため、INPUTをString型で定義)
    a = '1'
    b = '2'
    # 出力結果の予想
    expect = 'INPUTの値が正しくありません'
    # 実際の出力結果
    result = add_print(a, b)
    assert result == expect

さて、テストサンプル1にはついていかがでしたでしょうか。

テストサンプル1はpytestの基本的な部分ですので理解が早いと思います。
INPUTに対するOUTPUTの期待値を予め用意し、それらが一致すれば合格、というように非常にシンプルなテストになっているかと思います。

それでは次のケース、この記事の本題に行きましょう。

 

テストサンプル2

本題としてテストサンプル2に対してC1単体テストを実施していきましょう。
先ほどのテストサンプル1と同様に…。

単体テストではINPUTとそれに伴うOUTPUTの期待値を予め用意してテストしましたよね。
今回のテストサンプル2では上記の「期待値」についていかがでしょうか。

そうです、今回のケースではランダム数を返すため、そのままでは期待値を予測することができません

このような他の関数に依存、または時間数の中において、期待値を予測することができないケースというものには開発の中で往々にして出くわします。

そんな時に活躍するのが「モック」になります。

それでは今回のテストサンプル2のケースでは何をモック化すれば良いのでしょうか。
実際のテスト用コードを見ながら考えていきましょう。

from sample.py import random_print

# Test Sample 1 Test Code
# 正常系テスト

def test_sample_2_normal(mocker):
    テスト対象に注入するINPUT
    range = 100
    
    mock_return = 88
    # randomライブラリのrandomモジュールをモック化(返却値を88として置き換える)
    mocker.patch('random.random', return_value=mock_return)

    # 出力結果の予想(モックかしたことにより、random_int関数の返却値の期待値を予め設定できる)
    expect = 'ランダム数は88だよ'
    # 実際の出力結果
    result = random_print(range)
    assert result == expect

#異常系テスト
# 異常系テストではrandomライブラリが呼び出される前に例外が発生するためモック化は必要ない。
def test_sample_2_abnormal():
    # テスト対象に注入するINPUT(異常系試験のため、INPUTをString型で定義)
    range = '100'
    # 出力結果の予想
    expect = 'INPUTの値が正しくありません'
    # 実際の出力結果
    result = random_print(range)
    assert result == expect

テスト用コードいかがでしょうか。
肝は12行目のmocker.patchです。
今回はrandom_int関数で読み込んで使っているrandomライブラリのrandomモジュールをモックで置き換えて使っており、randomモジュールの返却期待値をmocker.patch関数のreturn_value引数に与え、結果として88と見なして処理をしています。

こうすることで予測ができない結果に対しても、モックを使い処理を置き換え、呼び出し元モジュールの単体テストを容易に実施することができるようになります。

mocker.patchのreturn_valueとは、モックで置き換えるモジュールに対して、どういう返却値を変えさせるかを指定する引数になっています。

また、mocker.patchではreturn_value以外にもside_effectという引数を持ち合わせており、side_effectとはモック対象のモジュール(今回であればrandom.randomモジュール)に対して、そのモック対象モジュールに注入された引数をside_effectで指定したモジュールにそっくりそのまま連携、指定したモジュールを実行し値を返す、という処理を行います。

例1)例外発生
モック化したいモジュールに対して例外を発生させたい時に下記のように使います。

mocker.patch('<mock-target-module>', side_effect=Exception('message'))

上記のように書くことで<mock-target-module>が呼ばれたときにExceptionを発生させることができ、Exceptionをキャッチする関数の分岐処理を故意に実行させることができます。

例2)return_valueと類似的に別関数を定義してside_effectに定義して使う
これはreturn_valueでいいではないか、と思うかもしれませんが、今回はこんな使い方もあるんだというお手本のためにわざとめんどくさい書き方をします。
先ほどのテストサンプル2に対する正常系テストを再現しながら説明します。

# side_effect用の実行モジュール
def mock_module(range):
    # 引数確認用
    print(range)
    return 88

def test_sample_2_normal(mocker):
    テスト対象に注入するINPUT
    range = 100
    
    # randomライブラリのrandomモジュールをモック化(side_effectに予め定義していたmock_moduleを渡す)
    mocker.patch('random.random', side_effect=mock_module)

    # 出力結果の予想(モックかしたことにより、random_int関数の返却値の期待値を予め設定できる)
    expect = 'ランダム数は88だよ'
    # 実際の出力結果
    result = random_print(range)
    assert result == expect

上記のように書いても、同様にrando_intの返却期待値として88を定義することができます。
ここでの肝はside_effectで指定したモック用モジュールの引数に、モック対象のモジュールに渡されるはずだった引数が渡る、という点です。

pytestにおけるMockの取り扱いまとめ

いかがだったでしょうか。
今回ご紹介したpytestにおけるモックの取り扱いについてまとめます。

  • pytestでモックを使うための準備(今回ご紹介したpytest-mockだけでなくMagicMockなどもあります)
pip install pytest-mock
  • pytest-mockの使い方

1 返却値を制御したい場合

mocker.patch('<mock-target-module>', return_value=<excepted_value>)

2 例外発生や別モジュールを実行させたい場合

mocker.patch('<mock-target-module>', side_effect=Exception('message'))

モックを使いこなして快適な単体テストライフを送ってください!

 

タイトルとURLをコピーしました