はじめに
ユニットテストでは処理に時間がかかったり、外部にアクセスする処理をモックで代替することがあります。 pytestでも pytest-mock
というモジュールでユニットテストをモック化することができます。
モックとは
オブジェクトの動作を再現するためのオブジェクトです。
例えばselect文の実行結果によって処理が分岐する場合、モックを使えばsqlを実行せずに結果だけを参照することができます。
これにより、テストの準備・実行コストを下げる効果が期待されます。
モックを利用すべき場面
- テストの準備にコストがかかる
例:
- データinsertが必要な処理
- サーバ上のファイルを利用する処理
- 外部APIを呼び出す処理
もちろん、データアクセスやファイル処理そのものを行うメソッドのテストは実際にDBやファイルを実行しなければなりません。しかしユニットテストにおいて、それらの処理の呼び出し元では改めて実処理を行う必要はありません。(DBアクセスやファイル処理の振る舞いは担保しているため)
モックで置き換えてよいでしょう。モックを使うことであらかじめデータを用意する手間が省けます。
- テストの実行にコストがかかる
例:
- 実行に時間の長いクエリを呼び出す処理
- 大量のデータを扱う処理
- sleep等、一定の時間がかかることが決まっている処理
テストケースが少ないうちは問題になりませんが、ケースが増えるとテストの実行時間が長くなり、開発効率が下がります。 必要に応じてモックを利用すればテストの実行時間を大幅に短縮できます。(特にJenkinsでユニットテストを実行する場合、往々にしてユニットテストの実行が他のジョブの邪魔になります。)
環境
- python3.8.10
- pytest6.2.5
- pytest-mock3.7.0
インストール手順
pipを使って環境構築します。
- モジュールのインストール
$ pip install pytest pytest-mock
- インストール確認
$ pip show pytest
pip show pytest
Name: pytest
Version: 6.2.5
Summary: pytest: simple powerful testing with Python
Home-page: https://docs.pytest.org/en/latest/
Author: Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others
Author-email: None
License: MIT
Location: /usr/local/lib/python3.8/dist-packages
Requires: iniconfig, py, packaging, pluggy, attrs, toml
Required-by: pytest-mock
$ pip show pytest-mock
Name: pytest-mock
Version: 3.7.0
Summary: Thin-wrapper around the mock package for easier use with pytest
Home-page: https://github.com/pytest-dev/pytest-mock/
Author: Bruno Oliveira
Author-email: nicoddemus@gmail.com
License: MIT
正常にインストールできていればokです。
使い方
オブジェクトの戻り値を固定する
import os
# テスト対象モジュール
def file_exists(file_path):
if not os.path.exists(file_path):
return "ファイルがありません"
return "ファイルがあります"
# テスト用関数
# mockerオブジェクトを引数に渡す
def test_file_exists_true(mocker):
# mocker.patchでオブジェクトの戻り値を固定
mocker.patch('os.path.exists' , return_value=True)
# 存在しないファイルパスを渡す
message = file_exists('test_file_path')
# 戻り値を検証
assert message == "ファイルがあります"
テスト対象のfile_exists()関数は引数で渡したファイルパスの存在を判定してメッセージを返します。 この関数をテストするためにモックを使ってみます。
mocker
はPython標準ライブラリunittestのラッパーオブジェクトで、テストメソッドに引数で渡して使います。
mocker.patch('os.path.exists' , return_value=True)
でos.path.existsの結果をTrueに固定します。
これにより、存在しないファイルパスを引数に渡した場合もfile_existsの戻り値が「ファイルがあります」となります。 モックにより、ファイルをサーバに配置するという手順をスキップすることができました。(本来はfile_exists自体のテストでは実際にファイルを配置すべきですが...)
例外を送出する
面倒なのが例外ケースのテストです。例外は通常起こりえない事象が起こった時に発生するものですから、再現しづらいことがあります。
そこで、モックによって強制的に例外を送出します。
import os
import pytest
# 例外処理を追加
def file_exists(file_path):
try:
if not os.path.exists(file_path):
return "ファイルがありません"
return "ファイルがあります"
except:
raise Exception("例外が発生しました")
def test_file_exists_exception(mocker):
# side_effectに例外を指定
mocker.patch('os.path.exists' , side_effect=Exception)
with pytest.raises(Exception) as e:
file_exists('test_file_path')
# エラーメッセージを検証
assert str(e.value) == "例外が発生しました"
raiseで例外を送出している場合、return_valueではなくside_effectで例外を指定する点に注意です。また、基底クラスExcetpionではなく、より具体的な例外クラスを指定することも可能です。
オブジェクトをモックする
プロダクトコードではユースケースやサービスクラスがリポジトリに依存することが多いです。
依存先のリポジトリをモック化すると効率的にユニットテストを実施できます。
# モック化するクラス
class UserRepository():
def __init__(self) -> None:
pass
# モック化する処理
def add(self, user):
try:
if user is not None:
return True
return False
except:
raise Exception("DBエラーが発生しました。")
# リポジトリに依存するクラス
class UseCase:
def __init__(self, repository: UserRepository) -> None:
self.repository = repository
def create_user(self, user):
return self.repository.add(user)
def test_usecase(mocker):
# リポジトリクラスのインスタンスをモック化
repository_mock: UserRepository = mock.MagicMock()
# mocker.patch.objectでリポジトリクラスの振る舞いをモック化
mocker.patch.object(repository_mock, 'add' , return_value=True)
usecase = UseCase(repository_mock)
message = usecase.create_user("山田さん")
# 戻り値を検証
assert message == True
mocker.patch.object()
はインスタンスメソッドやインスタンス変数をモックします。
第一引数にモック対象のオブジェクト・第二引数にモック化する変数やメソッド・第三引数にreturn_valueやside_effectを指定します。
今回はUserRepositoryインスタンスのaddメソッドをモック化し、戻り値をTrueに固定します。
(本来はaddメソッドでDBアクセスする想定です。 今回はサンプルということで処理を書き換えています)
これにより、DBアクセスという設定・実行に時間のかかる面倒な処理を気にしなくてよくなりました。 テストケースが増えるほどこのメリットは大きくなります。
よくある疑問・懸念
「モックを使ったテストに意味はあるのか?」 という疑問が当然出ると思います。
これについては「クラスやメソッドの責務が明確であれば意味がある」という回答になるでしょう。
今回の例で言うと、「UseCaseクラスのcreate_userメソッドはuser変数を受け取り、リポジトリのaddメソッドに渡す」という明確な責務を持ちます。 つまり、create_userメソッドのテストにおいてaddメソッドの振る舞い(DBアクセスが正しく動作すること)はテスト対象外のため、モック化してもかまいません。
addメソッドのテストではDBアクセス処理をモック化してはいけません。(当然ですが)
「責務に基づいて適切に分割されたモジュールはモックによるテストがしやすい」、逆に言うと「モックを使って効率的にテストを行うために責務に基づいてモジュールを分割すべき」ということになります。
参考資料
【pytest】mocker.patch.objectを使ってみる
[Python] pytest でモックを使う方法(pytest-mock)
pytestでMockを使う
unittest.mock --- モックオブジェクトライブラリ