Django でユニットテストを上手に扱うには、Freezegun や factory_boy を使うなど Django のドキュメントには書かれていない定番のテクニックがいくつかあります。
今回は、複数のモックを同時に扱う時に便利な contextlib の ExitStack を紹介します。
モックとパッチ
Django のユニットテストで外部通信を扱う場合、実際に通信をさせずにモックからレスポンスデータを返すことがよくあります。
例えば、UserPointAPIClient
というクラスがあり、ユーザーの持つポイントを返すメソッドがあるとします。
point_client = UserPointAPIClient(user) owned_point = point_client.get_point()
UserPointAPIClient は、会員サービスのAPIにリクエストしてポイントを取得する設計となっている場合、 ユニットテストにあたりAPIのサービス先の会員まで作るのは現実的ではありません。
そのため、ユニットテスト中は UserPointAPIClient
の get_point
メソッドを一時的にダミーの値を返すように変更します。
from django.test import TestCase
from unittest import mock
class UserPointTest(TestCase)
@mock.patch('user.client.UserPointAPIClient.get_point')
def test_user_point(self, get_point_mock):
# ユーザーが500ポイント持っていることにする
get_point_mock.return_value = 500
ポイントを使う処理...
このようにして、ポイントを使う手続き処理に一切手を入れることなく、外部へのAPI通信の戻り値を制御できます。
デコレーターではなく、コンテクストマネージャーでも扱えます。
from django.test import TestCase
from unittest import mock
class UserPointTest(TestCase)
def test_user_point(self):
with mock.patch('user.client.UserPointAPIClient.get_point') as get_point_mock:
# ユーザーが500ポイント持っていることにする
get_point_mock.return_value = 500
ポイントを使う処理...
mock.patch
の使い方は、 Django のドキュメントにはあまり詳しく書かれていません。 Python の unittest のドキュメントか、その他利用者のブログ記事が参考になります。
- Python の mock.patch のハマりやすい挙動についてまとめる #Python3 - Qiita
- Pythonのunittest.mock.patchではどこにパッチするかが重要 - Sogo.dev
- Pythonのunittestのmock.patchでハマった話(結局何をpatchすればいいのか) - [Dd]enzow(ill)? with DB and Python
複数のモックを扱う際の問題点
ユニットテストによっては、複数のAPIクライアントをパッチして複数のモックを扱う場面があります。
特に、ECサイトの購入処理を作る際は、
- 会員のポイントAPIから保有ポイントを取得する
- クレジットカードAPIから所有クレジットカードの一覧を取得する
- クレジットカードAPIにカード決済にリクエストを送信する
- デジタルコンテンツのライセンスAPIにライセンス獲得のリクエストを送信する
- 会員のポイントAPIにポイント消費のリクエストを送信する
- メッセージ送信APIにメッセージ送信のリクエストを送信する
等、複数のAPIリクエストが必要になります。
これらのAPIリクエストをモックしよとする場合、例えばデコレーターを使うと
from django.test import TestCase
from unittest import mock
class PurchaseTest(TestCase)
@mock.patch('user.client.UserPointAPIClient.get_point')
@mock.patch('payment.client.CreditCardAPIClient.get_owned_cards')
@mock.patch('payment.client.CreditCardAPIClient.authorize')
@mock.patch('product.client.LicenseManager.assign_license')
@mock.patch('user.client.UserPointAPIClient.use_point')
@mock.patch('messagesender.client.MessageClient.send_message')
def test_purchase(
self, send_message_mock, use_point_mock, assign_license_mock,
authorize_mock, get_owned_cards_mock, get_point_mock
):
send_message_mock.return_value = ...
use_point_mock.return_value = ...
assign_license_mock.return_value = ...
authorize_mock.return_value = ...
get_owned_cards_mock.return_value = ...
get_point_mock.return_value = ...
購入処理
上記のようになり、それなりのコード量になります。
1回だけならいいかもしれませんが、複数箇所に同じこをと書くのは避けたいところです。
コンテクストマネージャーで書こうとすると、インデントが深くなるのでさらに可読性が下がります。
from django.test import TestCase
from unittest import mock
class PurchaseTest(TestCase):
def test_purchase(self):
with mock.patch(
'user.client.UserPointAPIClient.get_point'
) as get_point_mock:
get_point_mock.return_value = ...
with mock.patch(
'payment.client.CreditCardAPIClient.get_owned_cards'
) as get_owned_cards_mock:
get_owned_cards_mock.return_value = ...
with mock.patch(
'payment.client.CreditCardAPIClient.authorize'
) as authorize_mock:
authorize_mock.return_value = ...
with mock.patch(
'product.client.LicenseManager.assign_license'
) as assign_license_mock:
assign_license_mock.return_value = ...
with mock.patch(
'user.client.UserPointAPIClient.use_point'
) as use_point_mock:
use_point_mock.return_value = ...
with mock.patch(
'messagesender.client.MessageClient.send_message'
) as send_message_mock:
send_message_mock.return_value = ...
購入処理
ネストが深く、読んでいて疲れるコードになってしまいます。
このような、複数のコンテクストマネージャーを同時に扱う場合は、contextlib の ExitStack を使うと解決できます。
ExitStack を使って複数のコンテクストマネージャーをまとめたデコレーターを作る
このようになります。
import contextlib
from functools import wraps
def _mock_apis(func):
"""
購入処理に関する外部API通信をモックするデコレーター
"""
@wraps(func)
def _decorator(*args, **kwargs):
with contextlib.ExitStack() as stack:
# ポイント取得をモックする
get_point_mock = stack.enter_context(
mock.patch('user.client.UserPointAPIClient.get_point')
)
get_point_mock.return_value = ...
# 所有クレジットカードをモックする
get_owned_cards_mock = stack.enter_context(
mock.patch('payment.client.CreditCardAPIClient.get_owned_cards')
)
get_owned_cards_mock.return_value = ...
# 決済オーソリをモックする
authorize_mock = stack.enter_context(
mock.patch('payment.client.CreditCardAPIClient.authorize')
)
authorize_mock.return_value = ...
# ライセンス取得をモックする
assign_license_mock = stack.enter_context(
mock.patch('product.client.LicenseManager.assign_license')
)
assign_license_mock.return_value = ...
# ポイント使用をモックする
use_point_mock = stack.enter_context(
mock.patch('user.client.UserPointAPIClient.use_point')
)
use_point_mock.return_value = ...
# メッセージ送信をモックする
send_message_mock = stack.enter_context(
mock.patch('messagesender.client.MessageClient.send_message')
)
send_message_mock.return_value = ...
# テスト関数の実行
func(
*args,
get_point_mock=get_point_mock,
get_owned_cards_mock=get_owned_cards_mock,
authorize_mock=authorize_mock,
assign_license_mock=assign_license_mock,
use_point_mock=use_point_mock,
send_message_mock=send_message_mock,
**kwargs,
)
return _decorator
このようなデコレーターを作っておけば、複数のテスト関数で共通化できて便利です。
class PurchaseTest(TestCase):
@_mock_apis
def test_purchase(self, *, authorize_mock, use_point_mock, **kwargs):
購入処理...
# 購入時のクレジットカードオーソリのリクエストを検証
self.assertEqual(authorize_mock.call_count, 1)
self.assertEqual(authorize_mock.call_args[1]['value'], ...)
# 購入時のポイントAPIのリクエスト状況を検証
self.assertEqual(use_point_mock.call_count, 1)
self.assertEqual(use_point_mock.call_args[1]['value'], ...)
...
@_mock_apis
def test_purchase_no_point(
self, *, get_point_mock, authorize_mock, use_point_mock, **kwargs
):
"""
所有ポイント以上のポイント使用をしようとした時の検証
"""
get_point_mock.return_value = 10
購入処理...
補足
property デコレーターをモックする場合
@property
のデコレーターをモックする場合は、patch
の new_callable
に PropertyMock
を入れます。
class UserPointAPIClient:
@property
def owned_point(self):
...
from unittest import mock
from unittest.mock import PropertyMock
...
owned_point_mock = stack.enter_context(
mock.patch(
'user.client.UserPointAPIClient.owned_point',
new_callable=PropertyMock,
)
)
owned_point_mock.return_value = 500