Python (3.3以降) でユニットテストのモックを楽に作れるunittest.mockが標準ライブラリとして提供されてます。今回はその紹介を行います。
外部モジュールに依存した実装をテストする難しさ
ユニットテストの実現において、DBやWeb APIなどのアプリケーション外のモジュールに依存している実装をどう扱うかは難しい問題です。
テストしやすいように (= 外部モジュールの影響範囲が小さくなるように) アプリケーションを適切に分割していくのも1つの手ですが、実務上、外部モジュールを含めたテストを完全には避けることは難しいです。
外部モジュールを含めたテストを行う場合、それらの振る舞いを擬態するモックが必要となります。
アプリケーションの機能とは直接関係のないテストのための実装なのですが、以下のように細かいケースに対応しないといけないので、なかなかの厄介者です。
- 関数に適切な引数が渡されているかどうかチェックしたい
- 返り値だけではなく、外部モジュールから例外がスローされた場合の分岐処理もテストしたい
- 呼び出しの回数・順序に応じて振る舞いが変わるケースもテストしたい
unittest.mock
unittest.mockを使ったテストは3ステップで行います。
- 外部モジュールを擬態するモックを作る => MagicMockで定義
- モックをテスト対象に埋め込んでテスト対象モジュールを実行 => patchで注入
- アサート (通常のunittestと同じ)
DBに依存したクラス (テストの対象)
DB (slite3) にアクセスするクラス・MemberModelを定義します。
sqlite3のmembersテーブルを更新するクラスで、create_members
とupdate_email
の2つのメソッドを持ちます
import sqlite3
class MemberEntity(object):
def __init__(self, id: int = None, name: str = None, email: str = None, nickname: str = None):
self.id = id
self.name = name
self.email = email
self.nickname = nickname
class MemberModel(object):
def __init__(self, db):
self.db = db
def create_members(self, members: list) -> []:
results = []
conn = sqlite3.connect(self.db)
for member in members:
try:
cur = conn.cursor()
sql = "insert into members (name, email) values (?, ?)"
id = cur.execute(sql, (member.name, member.email,)).lastrowid
default_nickname = "{}_{}".format(member.name, str(id))
sql = "update members set nickname = ? where id = ?"
cur.execute(sql, (default_nickname, id))
conn.commit()
results.append(MemberEntity(id, member.name, member.email, default_nickname))
except:
conn.rollback()
conn.close()
return results
def update_email(self, member: MemberEntity) -> int:
try:
with sqlite3.connect(self.db) as conn:
cur = conn.cursor()
sql = "update members set email = ? where id = ?"
cur.execute(sql, (member.email, member.id))
conn.commit()
except sqlite3.IntegrityError:
conn.rollback()
return 0
return 1
membersテーブルは以下で定義してます。ポイントは3点です。
- 自動採番される
id
を主キーとする
email
はユニーク制約付き
nickname
は{name}_{id}
で初期化する
- 自動採番されるidを含むため、INSERT後にUPDATEする必要がある
create table members
(
id integer
primary key,
name varchar(256) not null,
email varchar(256) not null
unique,
nickname varchar(256)
);
ユニットテストの実装
create_membersで1レコードをインサートしてMemberEntityオブジェクトを返す処理のユニットテストを以下で実装します。
import unittest
from unittest.mock import PropertyMock, MagicMock, patch
import sqlite3
from member import MemberEntity, MemberModel
class TestMemberModel(unittest.TestCase):
def test_create_members_1(self):
result_mock = MagicMock()
type(result_mock).lastrowid = PropertyMock(return_value=1)
cur_mock = MagicMock()
cur_mock.execute.return_value = result_mock
conn_mock = MagicMock()
conn_mock.cursor.return_value = cur_mock
with patch("sqlite3.connect", return_value=conn_mock):
members = [MemberEntity(None, "Name1", "Email1", None)]
model = MemberModel(db="dummy")
new_members = model.create_members(members)
conn_mock.commit.assert_called_once_with()
conn_mock.rollback.assert_not_called()
assert len(new_members) == 1
assert new_members[0].id == 1
assert new_members[0].nickname == "Name1_1"
... (続く) ...
MagicMockの生成
Step 1.のポイントはMagicMockを使ったモックオブジェクトの生成です。
MagicMockは任意のクラスや関数としての振る舞いを定義できます。
conn_mockはcursorメソッド、cur_mockはexecuteメソッド、result_mockはlastrowidプロパティを持つことが表現されています。プロパティはPropertyMockを使って定義します。
- MagicMockはMockクラスを継承しており、
__str__
などのいくつかのマジックメソッドが定義されたものです
MagicMockのメソッドの返り値は、return_valueによって任意の値やオブジェクトを設定できます。
MagicMockを入れ子にすることで、conn_mock.cursor()でcur_mock、cur_mock.execute()でresult_mockをそれぞれ返すようにしています。
result_mock = MagicMock()
type(result_mock).lastrowid = PropertyMock(return_value=1)
cur_mock = MagicMock()
cur_mock.execute.return_value = result_mock
conn_mock = MagicMock()
conn_mock.cursor.return_value = cur_mock
patch
Step 2.では生成したモックオブジェクトを、テスト対象のモジュールに外 (テストコード) から差し込み、 モジュールを実行してます。
この差し込みにはpatchを使います。
1つ目の引数 (traget) にsqlite3.connectを指定し、2つ目の引数 (return_value) に上で作成したconn_mockを渡しています。with句の区間内では、sqlite3.connect
が参照するオブジェクトはconn_mock
となります。
- with句以外にも、デコレータとして使うこともでき、関数内・クラス内といった柔軟なスコープで差し込みができます
それ以外は通常のテスト同様、対象のモジュールを実行してます。
with patch("sqlite3.connect", return_value=conn_mock):
members = [MemberEntity(None, "Name1", "Email1", None)]
model = MemberModel(db="dummy")
new_members = model.create_members(members)
MagicMockのアサーション
Step 3.では返り値のチェック等を行っています。
MagicMock特有の機能として、コールされたかどうかをチェックするassert_called_once_withやassert_not_calledなどがあります。
conn_mock.commit.assert_called_once_with()
conn_mock.rollback.assert_not_called()
assert len(new_members) == 1
assert new_members[0].id == 1
assert new_members[0].nickname == "Name1_1"
呼び出し履歴によってモックの返り値を変更する
members.nicknameは末尾に"_{id}"を付けることで、同じnameでも異なるデフォルト値になるようにしています。それを確認するユニットテストでは、同じexecuteの呼び出しでも異なるidを返すようにモックを実装する必要があります。
こういったケースではside_effectが役立ちます。
以下ではPropertyMock(side_effect=[1, 2])
として返り値のリストを渡すことで、1回目の呼び出しでは1、2回目の呼び出しでは2が返されるようにしてます。
class TestMemberModel(unittest.TestCase):
... (続き) ...
def test_create_members_2(self):
result_mock = MagicMock()
type(result_mock).lastrowid = PropertyMock(side_effect=[1, 2])
cur_mock = MagicMock()
cur_mock.execute.return_value = result_mock
conn_mock = MagicMock()
conn_mock.cursor.return_value = cur_mock
with patch("sqlite3.connect", return_value=conn_mock):
members = [
MemberEntity(None, "Name", "Email1", None),
MemberEntity(None, "Name", "Email2", None),
]
model = MemberModel(db="dummy")
new_members = model.create_members(members)
assert len(new_members) == 2
assert new_members[0].nickname != new_members[1].nickname
... (続く) ...
モックから例外をスローする
side_effectに例外オブジェクトを設定すると、呼び出しによって例外がスローされます。
members.emailのユニーク制約エラー (sqlite3.IntegrityError) をスローさせ、返り値が0になることをチェックしてます。
class TestMemberModel(unittest.TestCase):
... (続き) ...
def test_update_email_unique_error(self):
cur_mock = MagicMock()
cur_mock.execute = MagicMock(side_effect=sqlite3.IntegrityError())
conn_enter_mock = MagicMock()
conn_enter_mock.cursor.return_value = cur_mock
conn_mock = MagicMock()
conn_mock.__enter__.return_value = conn_enter_mock
with patch("sqlite3.connect", return_value=conn_mock):
member = MemberEntity(1, "Name1", "duplicated_email", "Name1_1")
model = MemberModel(db="dummy")
ret = model.update_email(member)
assert ret == 0
unittest.mockのつらいところ
ここまでunittest.mockでよく使う機能を中心に紹介しましたが、実際に使ってみるとつらいところというのもいくつかありました。
高機能なモジュールのモックの定義が冗長になりやすい
今回はsqlite3のconnectを起点にしたモックを作りましたが、ConnectionおよびCursorのそれぞれのメソッドとプロパティを置き換える必要があり、ちょっとめんどくさいです。
モックを使う場合でも、外部モジュールに依存する箇所が限定されるようにクラス設計する必要性は変わらないということですね。
モックの定義やアサートが、テスト対象モジュールの細かな実装に依存している
説明を省いたのですが、上の例ではcreate_membersとupdate_emailでコネクションの張り方が異なっています。前者はconn = sqlite3.connect(db)
、後者はwith sqlite3.connect(db) as conn:
で取得してます。
実装上は大きな違いでは無いのですが、モックの定義はそれぞれに合わせて変える必要があります。というのも、後者はConnection.__enter__()がオブジェクトを返すためです。
他にも、assert_called_once_withで引数のチェックもできるのですが、引数名の有無なども含めて厳密に一致する必要があります。
どんなアクセスでも受容するMagicMockの特性上このあたりは致し方ないのですが、書き方によってテストをpassしたりfailしたりするとちょっとストレスです。
コネクションの取得などは共通のモジュールにまとめる、細かい引数などのチェックは行わずにアウトプットでアサートできるようにクラス設計する、などの工夫が必要です。
まとめ
DBに依存したモデルクラスに対して、unittest.mockのモックを使ったユニットテストを実装し、使い方や欠点などをまとめました。