偷梁換柱:使用mock.patch輔助python單元測試


最近在搞軟工項目的后端測試,重新復習了一下python的mock.patch,並用它簡化了對一些復雜邏輯的測試,在此記錄

問題描述

本組的項目比較特殊,設計對教務網站的模擬登陸與信息爬取,同時不少接口會有發送郵件的side-effect。在自動化測試時,由於這兩個功能的行為與生產環境的真實數據(用戶的教務賬號、郵箱地址)耦合,需要想辦法設計專門的測試流程。容易想到的比較簡單的思路有:

  1. 為相關接口開一個標記測試的布爾值參數,在測試時傳入,屏蔽郵件發送/爬取教務網站的相關邏輯,並為郵件/爬蟲設計單獨的測試邏輯,將其與網站主要邏輯的測試解耦。好處是實現簡單,缺點是需要修改正常的接口邏輯,不符合開閉原則,且若處理不當易導致安全隱患。
  2. 提供一個專門的測試賬號,在測試時使用該賬號測試相關功能。優點是不需要修改接口邏輯,問題是對於爬取教務這種需求,提供的賬號是真實的學生賬號,自動測試時可預見的頻繁密集的數據請求可能會影響賬號的正常使用。

綜合上述兩個思路,不難想到去尋找一種可以跳過郵件發送/網站爬取邏輯但又不需要修改后端代碼邏輯的方法。由於python是解釋型語言,在程序運行時可以非常方便地將某一段代碼進行動態替換,所以只要在測試時將發送郵件的函數/方法替換成一個“假”函數即可。借助importlib等手段不難實現,但工作量稍大,實際上python已經為我們提供了unittest.mock.patch來滿足這種需求。

基本介紹

詳細使用請見官方文檔

一篇更簡明的介紹性質的教程是An Introduction to Mocking in Python

這里總結一些快速上手的要點

使用方式:裝飾器或上下文管理器

首先我們給出一個玩具函數func_to_test,這個函數接收兩個參數和一個可選參數,返回兩個參數的加和,並打印可選參數的值

# author      : Mistariano (hdl730@163.com)
# 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,同時輸出一行特定的信息。

下述兩種寫法都是可行的

# author      : Mistariano (hdl730@163.com)

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': 'tmp_email@mail.com'
        })
        self.assertEqual(User.objects.get(uid=self.TEST_USER_ID).email,
                         'tmp_email@mail.com')

        self._user_orm.email = self.TEST_USER_EMAIL
        self._user_orm.save()

    # ...


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2026 CODEPRJ.COM