質量體系建設之路---從介面測試開始基建

福祿網路研發團隊發表於2021-10-18

引言

中心內有大量的專案經過多年的迭代建設,無論是從體量、功能、複雜度都達到了一個無法完全依賴人工驗證交付的點。
我們和很多質量團隊一樣,隨著公司業務迅速的增長,前期質量環節主要依賴人工把控,在質量自動化工程建設上沒太多積累,面對如今的業務交付無論是從效率、質量上都逐漸暴露出明顯的短板。
開發及運維團隊已在CI/CD上進行了提效建設,木桶效應逐漸明顯,質量團隊也希望儘快突破自己的交付瓶頸,並能逐漸建立自己的技術專業線,於是我們分析了目前比較集中的痛點,識別出2個關鍵詞:迴歸提效、質量門禁。

迴歸提效怎麼做

由於系統的體量較大,面對每次交付的需求,我們一方面希望精準分析出本次需求的影響範圍,另一方面受限於交付週期及存在的變更風險壓縮測試工期。
20211014152055
要本質上解決這兩點,勢必全量回歸是保障質量最有效手段,集中驗證本次交付需求是確保順利交付的唯一途徑。
一個維護多年的產品,很難有人能夠針對每次改動分析全面涉及到的功能及業務場景,測試人員很希望能夠釋放資源將工作重點移至新功能驗證。
那麼接下來思路逐漸清晰了,為迴歸測試提效,減少人工參與。我們深入分析了目前團隊現狀及行業常規的自動化迴歸做法:UI層、API層。
20211014152205
介面測試路線成為我們首選,基於這個思路,我進一步走向平臺化。

平臺化勢在必行

要完成介面自動化建設,我們是使用現成的工具:Jmeter、Robot Framework,還是基於開源框架搭建工程,還是平臺化。基於三個方面考慮:
1、已完成相關技術人才儲備
2、開發&運維團隊已先行建設DevOps
3、質量工程化,工程效率化,構建TestOps
我們決定搭建屬於福祿自己的質量平臺,以介面自動化為“基建”.
20211014152242
平臺化後,我們可以充分利用現有的開發平臺,進一步完成從上至下的流程對接。

全員平臺建設

一旦平臺化後,有效的降低介面自動化用例編寫的技術門檻,深入了與現有的釋出流程“相容”,測試過程資料的持續沉澱為今後質量度量化提供基礎。
團隊內部我們進行了人員分工,長期參與專案交付的同學承擔起“產品經理”,有技術長處的同學承擔起“全棧工程師”,善於溝通協調的同學承擔起“專案經理,迅速的確定了功能流程。
20211014152344
考慮今後更便捷的與中心內部其他系統“打通”,我們基於福祿現有的前端框架進行打磨,後端選擇開發效率更高的python。
前端技術棧:antd(react) + apache echarts
後端技術棧:python + flask + blueprint
資料持久化:MySQL
20211014152505

平臺功能介紹

截止到目前,平臺基本已完成:
專案環境資訊->API蒐集->用例編寫->測試集建立->用例執行->測試報告

確保專案資訊上下一致性

確保我們測試專案、應用、環境、api swagger資訊從上至下是一致的,統一了資料來源。
20211014152525
很多公司在介面測試都面臨一個問題,到底我們介面覆蓋做到什麼程度,有沒覆蓋全,我怎麼知道?
為了解決這一痛點,平臺基於所有專案的api swagger進行解析,將api完整資訊落庫至平臺,基於落庫的api進行用例編寫,避免了測試人員需要人工收集、手動填寫、無法預知API量級......
20211014152629
將來想知道API覆蓋率輕而易舉,API的用例編寫到底寫到什麼程度,每個API如何設計的用例,平臺化的收益逐漸兌現。

既要高效寫用例,也要滿足複雜的實現

初期我們希望藉助平臺化極大降低編寫介面用例的技術門檻提高效率,將常用的編寫方式搬入了平臺。但是隨著深入的應用,很多複雜的場景涉及到:多介面上下文依賴、介面資料邏輯處理、資料庫操作.....這類場景如果全部平臺實現,顯然對平臺的設計提出了更高的要求及靈活度的實現,還會使得平臺功能略顯臃腫。於是我們設計出兩套用例編寫模式:使用者模式、專家模式。

使用者模式

大部分介面獨立存在,這些介面用例量大、編寫難度低、覆蓋場景清晰,那麼我們就在平臺上一次性寫好寫全,邊寫還能邊除錯,這才是正確的寫用例姿勢。
10jth-g3gaw
除錯過程中遇到介面異常,提供了自動生成分散式日誌連結,迅速通過日誌進行問題定位

專家模式

為了避免平臺功能過於臃腫,對於較為複雜的介面測試場景我們保留了“輕量化”程式碼途徑。使用python+unittest構建指令碼方式,一方面滿足複雜用例設計,另一方面測開人員習慣於指令碼編寫方式完成用例設計。
20211014152940
unittest本身提供了一套完整case管理、執行、測試報告解決方案,但是我們要將所有的用例統一管理、相容使用者、專家混合模式執行、測試報告整合......於是對框架進行了二次改造。
第一步、對用例進行打標,程式碼工程化的結構能夠移植到平臺上進行管理及執行排程
我們通過裝飾器屬性進行解析改造
Case層

@annotation.class_annotation(case_name='戰略大盤一縱上游商品大類', case_desc='戰略大盤一縱上游商品大類')
class UpstreamCases(RunTestCase):
    test_results = {}
    test_data = {}

    @classmethod
    def setUpClass(cls):
        # 如果本地unittest除錯執行,不從runMain入口執行,需要放開下面兩行註釋,此段用於設定用例的測試環境和連線自動化平臺的執行環境
        Config.set_env('Staging')

        cls.conf = ConfigRead()
        super().setUpClass_(cls.conf)
        cls.conn = MysqlQuery(cls.conf.SQL_SERVER, cls.conf.SQL_PORT, cls.conf.SQL_USER, cls.conf.SQL_PWD,
                              cls.conf.SQL_DATABASE)

    def run(self, result=None):
        """
        覆寫unittest.testcase的測試用例執行方法,執行完用例完成結果回填
        :param result: 測試結果引數
        :return:
        """
        # 如果本地unittest除錯執行,不需要將執行結果回寫平臺資料庫,可將第三個引數開關置為False
        super().runCase(self.test_results, result, False)
        self.test_results.clear()

    @annotation(steps_name='獲取戰略大盤一縱上游商品大類資料', steps_desc='獲取戰略大盤一縱上游商品大類資料',
                api_url=API_URL['upstream-goods-category'], api_method='POST')
    def test_01_save_member(self):
        data = {"startDate": "2021-01-01", "endDate": "2021-09-24"}
        data = json.dumps(data)
        path = urljoin(self.conf.FD_HOST, API_URL['upstream-goods-category'])
        headers = {'Content-Type': ContentType['json']}
        result = self.req.post_(path, headers, data=data)
        self.test_results['response'] = result.text
        self.test_results['status_code'] = result.status_code
        self.test_results['response_time'] = result.elapsed.total_seconds()
        verify_map = json.loads(result.content)
        data = verify_map['data']
        gmv = data['gmv']
        member_gmv = 0

        for item in data['list']:
            member_gmv = member_gmv + item['gmv']

        logger.info('獲取戰略大盤一縱下游行業大類資料成功: \n Response = ' + result.text)
        assert result.status_code == 200 and verify_map.get('message') == '成功!【戰略大盤一縱上游商品大類】' and gmv >= member_gmv

裝飾器解析

class MyAnnotation(object):
    def __init__(self, **kwargs):
        """
        解析測試類和測試方法註解的類裝飾器
        使用方法:
        1. 在測試類上方新增@MyAnnotation.class_annotation(...)
        2. 在測試方法上方新增@MyAnnotation(...)
        :param kwargs:
        """
        self.kwargs = kwargs

    def __call__(self, func):
        """
        方法裝飾器
        將註解內容解析出來存入方法的__annotations__屬性中,獲取時直接呼叫method.__annotations__
        :param func: function
        :return: function
        """
        for item in self.kwargs.items():
            key = item[0]
            value = item[1]
            func.__annotations__[key] = value
        return func

    @staticmethod
    def class_annotation(**keywords):
        """
        類裝飾器
        將註解內容解析出來存入類的__annotations__屬性中,獲取時直接呼叫class.__annotations__
        :param keywords: dict引數
        :return: class
        """

        def func(cls):
            class Wrapper(cls):
                def __init__(self, *args, **kwargs):
                    setattr(self, '__annotations__', {})
                    for item in keywords.items():
                        key = item[0]
                        value = item[1]
                        self.__annotations__[key] = value
                    super().__init__(*args, **kwargs)

            return Wrapper

        return func

    @staticmethod
    def generate_cases(class_list):
        """
        從class_list 獲取要解析用例註解的class名
        遍歷class找出用例註解,並生成cases.yml檔案
        :param class_list: class列表
        :return:
        """
        for item in class_list:
            cases = dict()
            module = importlib.import_module(item)
            class_ = inspect.getmembers(module, inspect.isclass)
            target = list(filter(lambda f: f[0].endswith('Cases'), class_))[0]
            class_name = target[0]
            target_class = target[1]
            loader = unittest.TestLoader().loadTestsFromTestCase(target_class)
            test_suite = loader._tests
            cases[class_name] = target_class().__annotations__
            cases[class_name]['case_path'] = item
            for i in test_suite:
                test_name = i._testMethodName
                case_an = getattr(target_class(), test_name).__annotations__
                cases[class_name][test_name] = case_an
            print(cases)
            MyYaml().write(os.path.join(rootPath, 'target', '%s.yml' % class_name), cases)

    @staticmethod
    def recursive_dir(items, result, is_check_path=True):
        """
        遞迴用例模組
        :param items: 要遞迴的目錄路徑或用例路徑
        :param result: 遞迴的結果
        :param is_check_path: 是否檢查路徑的開關,可不傳,保持預設即可
        :return:
        """
        for item in items:
            path = os.path.join(rootPath, item.replace('.', os.sep))
            if is_check_path:
                assert os.path.exists(path) or os.path.exists(path + '.py'), "不存在的路徑:" + path
                print('從%s掃描到模組如下:' % item)

            if os.path.isdir(path):
                files = [os.path.splitext(n)[0] for n in os.listdir(path) if not str(n).startswith('__')]
                MyAnnotation.recursive_dir(['.'.join([item, n]) for n in files], result, False)
            else:
                if str(path.split(os.sep)[-1]).endswith('Cases') and os.path.exists(path + '.py'):
                    result.append(item)
                    print(item)
                else:
                    continue

執行結果進行落庫,便於測試結果彙總分析,重寫runMain

def run_case(test_plan, field):
    """
    使用unittest testRunner 執行用例,執行結束後回填測試資料到平臺資料庫
    :param test_plan: 平臺測試計劃ID
    :param field: sql環境欄位
    :return: 
    """

    # 使用左連線查詢測試計劃下專家用例集
    sql = MysqlQuery(field.SQL_SERVER, field.SQL_PORT, field.SQL_USER, field.SQL_PWD, field.SQL_DATABASE)
    sql_s = 'select case_plan_info.id, set_id, case_plan_info.run_env, case_plan_info.app_id from case_plan_info left join case_set on case_plan_info.set_id = case_set.id where  plan_id = %d and run_mode = 1' % int(
        test_plan)
    query_list = sql.query(sql_s)

    for item in query_list:
        try:
            test_suite = unittest.TestSuite()
            case_report = {}
            steps_report = {}

            # 設定測試環境
            env = item['run_env']
            Config.set_env(env)
            print('設定環境引數為:', env)

            # 資料庫查詢出用例集中的用例
            s = "select case_id from case_set_info where set_id = %d and is_del = 0" % item['set_id']
            cases_set = sql.query(s)
            for case in cases_set:
                ss = 'select * from case_expert where id = %d' % int(case['case_id'])
                query_rr = sql.query(ss)
                class_ = query_rr[0]['case_class']
                import_path = query_rr[0]['case_path']
                case_name = query_rr[0]['case_name']
                app_id = query_rr[0]['app_id']

                # 執行用例前先在case_report中插入資料
                sql.query(
                    "insert into case_report (plan_id, set_id, app_id, case_id, case_name, run_env) values (%d, %d, %d, %d, '%s', '%s')" % (
                        test_plan, item['set_id'], app_id, int(case['case_id']), case_name, env))
                last_insert_id = sql.get_last_row_id()
                update_field = sql.query('select * from case_report where id = %d' % last_insert_id)[0]
                [update_field.pop(key) for key in ('create_time', 'update_time')]
                title = '_'.join([env, class_])
                case_report[title] = update_field

                # 查詢case用例中的步驟
                ss = "select * from case_steps where case_id = %d and case_name = '%s'" % (
                    int(case['case_id']), case_name)
                query_rr = sql.query(ss)
                method_list = []
                for step in query_rr:
                    method = step['method_name']

                    # 執行用例前先在steps_report中插入資料
                    sql.query(
                        "insert into steps_report (plan_id, steps_id, steps_name, case_id, case_name, run_env) values (%d, %d, '%s', %d, '%s', '%s')" % (
                            test_plan, step['id'], step['steps_name'], step['case_id'], step['case_name'], env))
                    last_insert_id = sql.get_last_row_id()
                    update_field = sql.query('select * from steps_report where id = %d' % last_insert_id)[0]
                    [update_field.pop(key) for key in ('create_time', 'update_time')]
                    none_list = [k for k, v in update_field.items() if v is None]
                    list(map(lambda f: update_field.pop(f), none_list))
                    sub_title = '_'.join([title, method])
                    globalVar.set_key(sub_title, update_field)
                    steps_report[sub_title] = update_field

                    # 動態import用例模組
                    module = importlib.import_module(import_path)
                    object_ = getattr(module, class_)
                    method_list.append(object_(method))
                test_suite.addTests(method_list)

            test_result = unittest.TextTestRunner(verbosity=2).run(test_suite)
            print(test_result)

            # 判斷測試執行結果,並回填測試資料到資料庫
            if test_result.wasSuccessful():
                for raw in case_report.values():
                    sql.query("update case_report set status = 1 where id = %d" % raw['id'])
            else:
                # 回填用例測試狀態 case_report
                for key, value in case_report.items():
                    steps_id = ','.join([str(v['id']) for k, v in steps_report.items() if k.startswith(key)])
                    step_status = sql.query(
                        "select count(status) as count from steps_report where id in (%s) and status = 1" % steps_id
                    )[0]['count']
                    if step_status == len(steps_id.split(',')):
                        sql.query("update case_report set status = 1 where id = %d" % value['id'])
                    else:
                        sql.query("update case_report set status = 2 where id = %d" % value['id'])

            # 回填測試狀態 case_plan_info
            case_id = ','.join([str(value['id']) for value in case_report.values()])
            case_status = sql.query(
                "select count(status) as count from case_report where id in (%s) and status = 2" % case_id
            )[0]['count']
            if case_status:
                sql.query("update case_plan_info set status = 2 where id = %d" % item['id'])
            else:
                sql.query("update case_plan_info set status = 1 where id = %d" % item['id'])
        except:
            print("runMain 執行異常,報錯如下:")
            traceback.print_exc()
            sql.query("update case_plan_info set status = 2 where id = %d" % item['id'])

根據平臺管理維度,生成Case指令碼轉為yml檔案,匯入至平臺進行統一管理
20211014153018

StoreCases:
  case_desc: 完成資料新增->查詢->修改->刪除
  case_name: 店鋪基礎流程
  case_path: src.cases.erp.storeCases
  test_01_create:
    api_method: POST
    api_url: /api/Store
    steps_desc: 新增店鋪
    steps_name: 新增店鋪
  test_02_find:
    api_method: GET
    api_url: /api/Store
    steps_desc: 查詢店鋪
    steps_name: 查詢店鋪
  test_03_update:
    api_method: PUT
    api_url: /api/Store
    steps_desc: 更新店鋪
    steps_name: 更新店鋪
  test_04_del:
    api_method: DELETE
    api_url: /api/Store
    steps_desc: 刪除店鋪
    steps_name: 刪除店鋪

20211014153137

質量門禁搭建

中心專案已完成容器化一鍵釋出,平臺與釋出流程高度整合,我的期望是每次完成發版後,自動觸發介面用例的執行,並且根據不同的測試環境及迭代情況,定製我們的覆蓋範圍。測試人員在每次收到提測通知前,介面測試先行自測,根據測試結果判斷是否滿足提測要求。
20211014153218
完成CD後自動執行所配置的測試集,並已釘釘訊息方式通知到相關測試人員,檢視測試結果。
20211014153237
20211014153255

我們接下來

TestOps是我們未來長期建設的目標,我們將基於介面測試為基礎,逐步完善我們的專業線基建,使得我們“質量門禁”更豐富......
我們還是一個“年輕的”質量團隊,在質量工程建設上現在僅僅邁出了第一步,接下來我們正在設計不一樣的“資料工廠”、無程式碼化的mockserver.......盡請期待☺
未來我們逐漸完善平臺功能後,也會進行開源,希望更多朋友一起參與到質量工程建設當中,一起交流討論實踐心得!

福祿·研發中心 福壹

相關文章