偷梁换柱:mockを使う.patch補助pythonユニットテスト

6918 ワード

最近ソフトワークプロジェクトのバックエンドテストをして、pythonのmock.patchを再復習して、それを使っていくつかの複雑な論理に対するテストを簡略化して、ここで記録します

問題の説明


本グループのプロジェクトは比較的特殊で、教務サイトへの模擬登録と情報の登り取りを設計し、同時に多くのインタフェースにメールを送信するside-effectがある.テストを自動化する際、この2つの機能の行為は生産環境の実際のデータ(ユーザーの教務アカウント、メールアドレス)と結合しているため、専門的なテストプロセスを設計する方法が必要である.考えやすい簡単な考え方は次のとおりです.
  • 関連インタフェースにタグテストのブール値パラメータを開き、テスト時に送信され、教務ウェブサイトの関連ロジックを送信/爬取することを遮断し、メール/爬虫類のために個別のテストロジックを設計し、ウェブサイトの主要ロジックのテストとデカップリングする.利点は実現が簡単で、欠点は正常なインタフェースロジックを修正する必要があり、開閉原則に合致せず、処理が適切でないと安全上の危険を招きやすいことである.
  • は、テスト時にこのアカウントを使用して関連機能をテストする専門のテストアカウントを提供します.利点はインタフェースロジックを修正する必要がないことであり、問題は教務を這い出すという需要に対して、提供されたアカウントは真実の学生アカウントであり、自動テスト時に予測できる頻繁に密集したデータ要求はアカウントの正常な使用に影響を与える可能性がある.

  • 上記の2つの考え方を総合すると,メール送信/ウェブサイトをスキップして論理を抽出できるが,バックエンドコード論理を修正する必要がない方法を探すことは容易ではない.pythonは解釈型言語であるため、プログラムの実行時にコードを動的に置き換えるのが非常に便利であるため、テスト時にメールを送信する関数/方法を「偽」関数に置き換えるだけでよい.importlibなどの手段で実現するのは難しくないが、作業量はやや大きく、実際にはpythonはunittest.mock.patchを提供してこのようなニーズを満たしている.

    基本的な紹介


    詳細は公式ドキュメントを参照してください
    より簡明な説明の性質のチュートリアルはAn Introduction to Mocking in Pythonです.
    ここでは素早いポイントをまとめてみましょう

    使用方法:デザイナまたはコンテキストマネージャ


    まず、おもちゃ関数func_to_testを与え、この関数は2つのパラメータと1つのオプションパラメータを受信し、2つのパラメータの加算を返し、オプションパラメータの値を印刷する.
    # author      : Mistariano ([email protected])
    # file path   : pack1/my_module.py
    # module name : pack1.my_module
    
    def verbose_adder(arg1, arg2, kwarg1='default'):
        print(kwarg1)       # side-effect
        return arg1 + arg2  # post-condition
    
    def func_to_test():
        return verbose_adder(10, 10)
    
    
    patchを借りて、hackはverbose_adderという関数を削除することを望んでいます.テスト時func_to_testが実際にverbose_adderに伝達するパラメータが何であるかにかかわらず、その戻り値は3であり、特定の情報の行を出力することが望ましい.
    下記の2つの書き方が可能です
    # author      : Mistariano ([email protected])
    
    from pack1.my_module import func_to_test
    from unittest import mock
    
    
    def print_test_info(*args, **kwargs):
        print('this is a microphone check.')
        print('arguments:', args, kwargs)
        return mock.DEFAULT  # NOTICE here
    
    @mock.patch('pack1.my_module.verbose_adder')
    def test_func_to_test__decorator(mock_obj):
        mock_obj.return_value = 3
        mock_obj.side_effect = print_test_info
        assert func_to_test() == 3
    
    def test_func_to_test__context():
        with mock.patch('pack1.my_module.verbose_adder') as mock_obj:
            mock_obj.return_value = 3
            mock_obj.side_effect = print_test_info
            assert func_to_test() == 3
    
    mock.patchは、装飾された関数が追加のパラメータを提供してmockオブジェクトインスタンスmock_objを受信する必要がある関数装飾またはコンテキストマネージャとして使用することができ、後者はmockオブジェクトインスタンスをコンテキストマネージャの戻り値として使用することができる.もちろん,直接関数として呼び出すのも望ましいが,個人的には推奨されず,ここでは詳細には論じない.mock_objに戻り値(オプション)と副作用(オプション)を指定することによってmock関数の動作をカスタマイズし、元の関数/メソッドの動的オーバーライドを実現する
    なお、mockオブジェクトside_effectとして使用されるコールバック関数の戻り値はmock.DEFAULTであり、これは、別途定められたreturn_valueを上書きしないようにするためである

    どの関数にpatchを打つべきですか


    Mock an item where it is used, not where it came from.
    pythonのロードメカニズムは面白いです.関数の場合、mockで指定したモジュールパスが実際に呼び出された場所ではなく定義されている場合、mockはこの関数をロードした他のモジュールを正常に上書きできない可能性があります.
    この問題の詳細な説明は公式文書を参照することができ,このStackoverflow質問はいくつかの例を示し,さらなる理解に役立つ.

    実戦


    ここでは,本グループのソフトワークコードのうちpatchを用いてメール送信および教務爬取を上書きするコードセグメントを直接与える.
    from django.test import TestCase
    from unittest import mock
    # ...
    
    
    class ViewTestCases(TestCase):
    
        # ...
    
        @staticmethod
        def mock_mail_send(*args, **kwargs):
            print('sending mock mail.. args:', args, kwargs)
            return mock.DEFAULT
    
        @staticmethod
        def mock_update_from_course(*args, **kwargs):
            print('mock updating course... args:', args, kwargs)
            return mock.DEFAULT
    
        def _test_req_context(self, func, exp_code, auth_required):
            def test_req_wrapper(*args, **kwargs):
                token = None if not self._user_data else self._user_data['token']
                with mock.patch('ddl_killer.utils.sendmail.YAG.send') as mail_obj:
                    mail_obj.side_effect = self.mock_mail_send
                    with mock.patch('ddl_killer.views.updateFromCourse') as mock_course:
                        mock_course.side_effect = self.mock_update_from_course
                        mock_course.return_value = self.TEST_COURSE
    
                        if auth_required:
                            r_data = func(*args, HTTP_AUTHORIZATION=None, **kwargs).json()
                            self.assertEqual(r_data['code'], 401, r_data)
                        r_data = func(*args, HTTP_AUTHORIZATION=token, **kwargs).json()
                        self.assertEqual(r_data['code'], exp_code)
                        return r_data
    
            return test_req_wrapper
    
        def post(self, *args, exp_code=200, auth_required=True, **kwargs):
            return self._test_req_context(self._client.post, exp_code, auth_required)(*args, **kwargs)
    
        def get(self, *args, exp_code=200, auth_required=True, **kwargs):
            return self._test_req_context(self._client.get, exp_code, auth_required)(*args, **kwargs)
    
        def _login(self):
            if self._user_data is None:
                print('logging...')
                r = self.post('/api/login',
                              {'uid': self.TEST_USER_ID,
                               'password': self.password_encrypt},
                              auth_required=False)
                self._user_data = r
    
        def test_show_user(self):
            self._login()
            data = self.post('/api/user/{}/info'.format(self.TEST_USER_ID))
            self.assertEqual(data['uid'], self.TEST_USER_ID)
            self.assertEqual(data['name'], self.TEST_USER_NAME)
            self.assertEqual(data['email'], self.TEST_USER_EMAIL)
    
        def test_user_login_not_activated(self):
            self._user_orm.is_active = False
            self._user_orm.save()
            r = self.post('/api/login', {'uid': self.TEST_USER_ID,
                                         'password': self.password_encrypt},
                          exp_code=400,
                          auth_required=False)
            self._user_orm.is_active = True
            self._user_orm.save()
    
        def test_edit_user(self):
            self._login()
            data = self.post('/api/modify', {
                'uid': self.TEST_USER_ID,
                'name': self.TEST_USER_NAME,
                'password': '',
                'email': '[email protected]'
            })
            self.assertEqual(User.objects.get(uid=self.TEST_USER_ID).email,
                             '[email protected]')
    
            self._user_orm.email = self.TEST_USER_EMAIL
            self._user_orm.save()
    
        # ...