Djangoのユニットテストで複数のモックを同時に扱う時は ExitStack でまとめると良い


Django でユニットテストを上手に扱うには、Freezegun  factory_boy を使うなど Django のドキュメントには書かれていない定番のテクニックがいくつかあります。

今回は、複数のモックを同時に扱う時に便利な contextlib  ExitStack を紹介します。

モックとパッチ

Django のユニットテストで外部通信を扱う場合、実際に通信をさせずにモックからレスポンスデータを返すことがよくあります。

例えば、UserPointAPIClient というクラスがあり、ユーザーの持つポイントを返すメソッドがあるとします。

point_client = UserPointAPIClient(user)
owned_point = point_client.get_point()

UserPointAPIClient は、会員サービスのAPIにリクエストしてポイントを取得する設計となっている場合、 ユニットテストにあたりAPIのサービス先の会員まで作るのは現実的ではありません。

そのため、ユニットテスト中は UserPointAPIClientget_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 のドキュメントか、その他利用者のブログ記事が参考になります。

複数のモックを扱う際の問題点

ユニットテストによっては、複数の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
現在未評価

コメント

コメントを投稿
コメントするには TORICO-ID にログインしてください。
ログイン コメント利用規約