自動化測試框架開發(fā)實錄

最近入職了新公司,負責自動化測試相關(guān)的工作,那么首先當然是自動化測試平臺的開發(fā)了。經(jīng)過一個多月的奮戰(zhàn),到現(xiàn)在功能基本完成,結(jié)果還是比較滿意和有成就感的,過程很受鍛煉,其中的思考、經(jīng)驗、知識點、總結(jié)等,打算寫個系列文章記錄下來。
言歸正傳。

框架設(shè)計

image

如上圖,根據(jù)分層測試理論,單元測試、集成測試和系統(tǒng)測試的資源投入比例70:20:10是比較合理的。
單元測試一般會由開發(fā)自己覆蓋,那么,作為自動化測試人員,關(guān)注點應(yīng)該主要放在service層,UI適當兼顧,所以,我的目標就是做一個好用的、能滿足系統(tǒng)集成測試和UI測試的、方便接入持續(xù)集成等功能的測試平臺,滿足回歸測試、線上監(jiān)控等需求。?
首先的問題是技術(shù)選型。
自動化測試的開發(fā)大概有兩種模式。
第一種是用例和代碼邏輯分離,用例基于某種模板生成文本文件,然后用某種轉(zhuǎn)換的方式去驅(qū)動底層的代碼執(zhí)行完成測試。這種方式的優(yōu)點在于,寫出來的用例清晰易懂,合作分工,學習成本低,易于在團隊推廣等。我在上家公司就是采用這種方式基于lettuce開發(fā)的一個BDD框架,效果總的來說還不錯,但它也有固有的缺點,最大的缺點在于不靈活;其次,其實對于代碼開發(fā)人員來說,用例轉(zhuǎn)換是一個多余的動作,實際上是加大了專門的做自動化測試的人員的工作成本,就我的實踐而言,對于不會代碼也不愿意去學代碼的同學,無論怎么樣變換形式,興趣啊積極性啊等等其實很難被激發(fā)起來,工作關(guān)鍵在于興趣和自覺,外力感覺作用不太大。
所以我打算這次選第二種方式,也就是純代碼開發(fā)的方式。關(guān)于這個問題,我也在網(wǎng)上搜了搜,發(fā)現(xiàn)大多數(shù)同學也是傾向于純代碼開發(fā),尤其是老鳥,為了成為老鳥,這更堅定了我的選擇。
方向確定了,接下來就是方案了。
在Python生態(tài)里,測試框架還是挺多的,unittest、nose等我也用過,但是感覺功能偏少,擴展也不便,pytest知道但沒有實際用過,深入了解之后,發(fā)現(xiàn)就倆字,好用!無論是fixture,自身,參數(shù)化等,還是配合allure生成測試報告,簡潔優(yōu)雅又強大,一如Python,決定就選pytest了。
方案也確定后,便是設(shè)計,先上圖。


image

根據(jù)我的經(jīng)驗總結(jié),開個一個框架,大概可以分兩步走。第一步,自底而上,主要是一些底層邏輯的實現(xiàn),比如http客戶端、log、異常等等;第二步,自上而下,主要是用例相關(guān),比如設(shè)計用例的開發(fā)方式、用例的執(zhí)行過程等等??梢钥吹?,圖中大致可以分為兩個部分,工具集和用例,我在設(shè)計的時候思考了很多,只求在正式寫用例時能寫的爽。
下面就一些主要模塊分別講述下。

httpManager

包括httpRequest, httpResponse, interface三個對象。
httpRequest組合了requests.Session對象,既可以使用requests的強大功能,同時加入了一些自己的設(shè)計

def__call__(self, *args, **kwargs):
        """
       1.調(diào)用請求客戶端處理請求
       2.調(diào)用響應(yīng)處理器處理響應(yīng)結(jié)果,返回
       :param args: 請求參數(shù)
       :param kwargs: 請求參數(shù)
       :return: 請求結(jié)果
       """
        combination_url(kwargs)
        arguments=kwargs.get('data')orkwargs.get('params')orkwargs.get('json')
        api=kwargs.get('url')
        # 將參數(shù)值轉(zhuǎn)為json格式
        fork,vinarguments.items():
                if isinstance(v, (str, bytes)):
                      continue
                arguments[k] = json.dumps(v, cls=CustomJsonEncoder)

        with self.clientasclient:
                try:
                    response = client.request(*args, **kwargs)
                    except requests.exceptions.ConnectionError as e:
                            self.logger.exception(e)
                            sys.exit('請求%s訪問不通, 測試終止'%api)
                self.logger.info('請求接口: %s',response.url)
                self.logger.info('請求參數(shù): %s',arguments)
                try:
                        return http_response(response, api, arguments)
                except (APIReusltIsNoneError, APIResponseError) as e:
                        self.logger.exception(e)
                        return False

主要是實現(xiàn)了call特殊方法,這樣只需要實例化一個請求對象,通過傳入不同參數(shù),而完成不同的請求。
httpResponse同樣實現(xiàn)了call方法,主要是解析response,一些特殊的接口可以在這里集中處理。
interface是一個裝飾器,在我的想法里,接口的配置和接口的執(zhí)行是分開的,interface起到的是一個整合的作用。當然,這里要搭配接口定義來講。

def interface(**kw):
        """
       接口裝飾器,用于定義服務(wù)端的接口訪問
       :param kw: 接口參數(shù)信息字典
       :return: 接口請求返回值
       """
        # 獲取接口輸入信息
        interface_info=kw
        def decorator(f):
                @wraps(f)
                def wrapper(*args, **kwargs):
                        # 獲取實際接口請求參數(shù)
                        actual_args=f(*args,**kwargs)
                        # 將請求參數(shù)合并進接口請求信息中
                        if 'params' in interface_info:
                                interface_info.update({'params': actual_args})
                        elif 'data' in interface_info:
                                interface_info.update({'data': actual_args})
                        elif 'json' in interface_info:
                                interface_info.update({'json': actual_args})
                        # 執(zhí)行HTTP請求
                        return http(**interface_info)
                return wrapper
        return decorator

apiManager

關(guān)于接口的訪問,我更傾向于將接口定義成本地的方法,這樣用的時候直接調(diào)用就可以了。
一般來說,接口的定義形式都差不多,不過是一些參數(shù)的不同,如果一個個去寫成方法定義,那么會重復寫很多的樣式代碼,肯定是不可取的。我的解決方式是通過元類,該元類的作用是在創(chuàng)建類時自動將類屬性轉(zhuǎn)化為類方法。

class InterfaceMetaClass(type):
        """
        接口配置類的元類,會自動將配置的類屬性轉(zhuǎn)化為同名靜態(tài)方法
        """
        def__new__(cls, name, bases, attrs):
                for k, v in attrs.items():
                        if not k.startswith('__'):
                                v.update({'cls_name': name})
                                def wrapper(v):
                                    f = lambda **kw: kw
                                    return interface(**v)(f)
                                attrs[k] = staticmethod(wrapper(v))
                return super().__new__(cls,name,bases,attrs)

簡單地說,我會根據(jù)不同的服務(wù)接口定義不同的接口配置類,并將該類的元類設(shè)置為InterfaceMetaClass,然后在類屬性中配置接口信息,包括url、method、params等,這些信息等同于requests庫中的請求參數(shù)信息,會直接傳給requests做請求,完全不用做任何額外處理。

class RedictAPI(object, metaclass=InterfaceMetaClass):
    redict= {
        'method':'get',
        'url':'/redict/',
        'params': {},
        'allow_redirects':False
   }

如圖,如此我們對于一個接口的訪問是異常清晰的,跟填空題一樣,也很方便管理。
RedictAPI類便有了一個redict方法,調(diào)用時傳入params參數(shù)就可以發(fā)送請求了,當然還有個要說明的點就是url,可以看到圖中url并沒有域名、端口等,這樣肯定是訪問不通的。因為在測試的時候肯定要滿足不同的環(huán)境需求,服務(wù)地址是動態(tài)的,因此url要動態(tài)拼接,拼接操作發(fā)生在請求對象發(fā)送請求之前,調(diào)用combination_url。

def combination_url(v):
    """
   拼接域名和api,組成完整的URL
   :param v:
   :return:
   """
    cls_name = v.pop('cls_name')
    v['url'] = ''.join([bxmat.url.get(cls_name)+v['url']])

域名等配置信息統(tǒng)一配置在配置文件里,代碼運行時動態(tài)導入到名為bxmat的自定義內(nèi)置變量里,拼接時便可以從中取值。

services

再往上到services一層,便是請求參數(shù)的處理。大多數(shù)的接口都會有很復雜的參數(shù),在寫用例時不可能每次都寫一堆參數(shù)上去,因此,這一層主要是封裝底層接口調(diào)用,暴露出參數(shù)信息,為參數(shù)化、數(shù)據(jù)驅(qū)動等做準備。

@staticmethod
def adpopup_changestatus(id=0,popupStatus=0):
    """
    :param id:
    :param popupStatus:
    :return:
    """
    arguments = locals()
    return ActivitiesAPI.adpopup_changestatus(**arguments)

dbManager

數(shù)據(jù)庫操作以mysql為例,我封裝了sqlalchemy,使得可以操作已有的表。

from sqlalchemy.orm.exc import UnmappedClassError
from sqlalchemy.ext.declarative import declared_attr, declarative_base
from sqlalchemy import create_engine, MetaData, Table
from sqlalchemy.orm import sessionmaker, class_mapper, Query

Base=declarative_base()


class _QueryProperty(object):

    def __init__(self, sa):
        self.sa=sa

    def __get__(self, obj, t):
        try:
            mapper = class_mapper(t)
            if mapper:
                return t.query_class(mapper,session=self.sa.session)
        except UnmappedClassError:
            return None


class DbRoot(object):

    def __init__(self,**kwargs):
        """
       orm基礎(chǔ)db對象,通過實例化該對象得到db實例,然后創(chuàng)建類對象繼承自db.Model,便可以對相應(yīng)表進行操作
       :param kwargs: dialect 數(shù)據(jù)庫類型
                       driver 數(shù)據(jù)庫驅(qū)動
                       user 用戶名
                       password 用戶密碼
                       host 數(shù)據(jù)庫地址
                       port 端口
                       database 數(shù)據(jù)庫名
       """
        url = '{dialect}+{driver}://{user}:{password}@{host}:{port}/{database}?charset=utf8'.format(**kwargs)
        engine=create_engine(url,echo=False)

        class Base(object):
            @declared_attr
            def__table__(cls):
                return Table(cls.__tablename__, MetaData(), autoload=True, autoload_with=engine)

        self._base = Base
        self.Model = self.make_declarative_base()
        self.session = sessionmaker(bind=engine)()

    def make_declarative_base(self):
        base = declarative_base(cls=self._base)
        base.query=_QueryProperty(self)
        base.query_class=Query
        return base

實例化DbRoot對象可以生成一個db對象,然后通過gen_orm_class便可以得到一個表對象然后對該表進行操作。

def gen_orm_class(db_name=None, db=None, table_name=None):
    """
   動態(tài)生成數(shù)據(jù)庫表映射Model類
   :param db: db對象
   :param table_name: 表名稱
   :return:
   """
    if db_name and isinstance(db, dict):
        db=db.get(db_name)
    return type(
        table_name.title(),
        (db.Model,),
       {
            '__tablename__': table_name
       }
   )

以上算是底層工具,為了方便自動化測試設(shè)計和用例開發(fā),我用這些工具結(jié)合pytest做了進一步的封裝。

fixtures

fixture是pytest測試框架的最大亮點之一,它的概念很模糊,難以準確描述,本質(zhì)上只是一個被pytest.fixture裝飾的函數(shù),但是pytest的運行機制為這個函數(shù)賦予了神奇的魔力,它既可以去做setup、teardown這樣的事情,又可以被當做數(shù)據(jù)容器傳值。
比如,生成用戶id的場景,在其他fixture中使用users就可以直接使用該函數(shù)返回值。

@pytest.fixture(scope='module')

@DataFixtures()

def users(request):
    return MyList([gen_uid(n=n) for n in range(request.module.config['users'])])

這樣就可以直接封裝好一些data_fixture,在寫用例時直接使用就可以了。
fixture有兩種teardown的方式。第一種是通過生成器,這種方式簡潔優(yōu)雅,但是如果有返回值時,因為是生成器,取值時要通過next(users),當在pytest.mark.parametrize中使用next(users)會造成stopIteration異常;并且一旦yield前面的代碼報錯,teardown是不會執(zhí)行的。

@pytest.fixture(scope='module')
@DataFixtures()
def users(request):
    print('start gen users')
    yield MyList([gen_uid(n=n)forninrange(request.module.config['users'])])
    print('end gen users')

第二種方法是向request.addfinalizer注冊teardown函數(shù),這種方式會強制執(zhí)行,不管前面的代碼是否報錯

@pytest.fixture(scope='module')
@DataFixtures()
def users(request):
    def finalizer():
        print('end gen users')
    request.addfinalizer(finalizer)
    return MyList([gen_uid(n=n)for n in range(request.module.config['users'])])

parametrizes

parametrize是pytest提供的數(shù)據(jù)驅(qū)動測試功能,非常方便,通過pytest.mark.parametrize的裝飾,可以方便的向測試方法傳入?yún)?shù)化數(shù)據(jù)。
比如這樣,add_activity接口已經(jīng)被改造成了非常方便做數(shù)據(jù)驅(qū)動測試,通過這樣的封裝,在用例層面,便可以寫出簡潔的代碼

def add_activity(file='add_activity_conf.json', **kwargs):
    """
   增加活動
   :param file:
   :param kwargs:
   :return:
   """
    data = add_template_code(file=file, **kwargs)
    return ActivityService.add_activity(**data)

dataManager

在數(shù)據(jù)驅(qū)動測試時,我希望有一個統(tǒng)一的數(shù)據(jù)接口來管理測試數(shù)據(jù),解析它們并往pytest.mark.parametrize傳。

def data_interface(dir=None,file=None,parametrize=True):
    """
   測試數(shù)據(jù)統(tǒng)一接口
   :param dir: 測試數(shù)據(jù)目錄
   :param file: 測試數(shù)據(jù)文件名
   :param parametrize: 是否轉(zhuǎn)化為參數(shù)化的數(shù)據(jù)
   :return: 測試數(shù)據(jù)
   """
    if dir and file:
        data=file_load(dir,file)
        # 轉(zhuǎn)義測試數(shù)據(jù)中的特殊值,如${gen_uid(10)}
        pattern_function=re.compile(r'^\${([A-Za-z_]+\w*\(.*\))}$')
        def my_iter(data):
            """
            遞歸配置文件,根據(jù)不同數(shù)據(jù)類型做相應(yīng)處理,將模板語法轉(zhuǎn)化為正常值
            :param data:
            :return:
            """
            if isinstance(data, (list,tuple)):
                for index,_data in enumerate(data):
                    data[index] = my_iter(_data) or _data
            elif isinstance(data,dict):
                for k,v in data.items():
                    data[k] = my_iter(v) or v
            elif isinstance(data, (str,bytes)):
                  m=pattern_function.match(data)
                  if m:
                      return eval(m.group(1))
                  return data

        my_iter(data)

        if parametrize:
            return [tuple(x.values()) for index,x in enumerate(data)]
        else:
            return data
    return None

以上粗略介紹了caseToolkits,接下來便是用例部分。
至此,我們已經(jīng)可以寫出這樣的測試用例。用例的數(shù)據(jù)和代碼都可以根據(jù)實際測試場景開發(fā),增加case只需要往測試數(shù)據(jù)文件里面填數(shù)據(jù)就可以了。

@pytest.mark.usefixtures('config_init')
@allure.feature('增加活動')
class TestAddActivity(object):
    @pytest.mark.parametrize("id, data, validators",data_interface(dir=base_dir,file='test_add_activity.json'))
    @Decorator()
    def test_add_activity(self, id, data, validators,mysql):
        with allure.step(data.pop('stepName')):
            activity_id=add_activity(**data)
            assert activity_id
            validator(validators,mysql=mysql,tbl_id=[activity_id])

pytest有一個默認的入口文件叫conftest.py,是根據(jù)約定大于配置的思想定義的,這個文件很有意思,在它里面可以自定義擴展命令行參數(shù),可以定義fixture而不需要在寫用例使用時導入等,更重要的是還可以在里面做一些初始化的工作。
像上面有提到一個bxmat的內(nèi)置變量,它很關(guān)鍵。它的實現(xiàn)如下,可以看到它被添加到了builtins的dict屬性字典里面,所以可以在代碼任何地方訪問到,算是個小魔法。

builtins.__dict__.update({'bxmat':MyDict()})

比如自定義一個測試環(huán)境的命令行參數(shù),這在測試時很有用,這樣測試時便可以方便的指定測試環(huán)境,然后通過request.config.option來取環(huán)境參數(shù),從而導入相應(yīng)的配置信息.

def pytest_addoption(parser):
    """
   增加測試環(huán)境命令行參數(shù)
   :param parser:
   :return:
   """
    parser.addoption(
        "--te",
        action="store",
        default="dev",
        dest="TEST_ENVIRONMENT",
        help="Specify a test environment"
   )

@pytest.fixture(scope='session')
def te(request):
    """
   自定義的測試環(huán)境變量
   :param request:
   :return:
   """
    env = request.config.getoption("--te")
    return env

還有個問題,就是在寫測試數(shù)據(jù)時,如果我們要構(gòu)造一些動態(tài)生成的字段值,典型的比如uid等,手工寫肯定是很蠢的事情,但在文本文件里面怎么做呢?這里我借鑒了模板語法

{"name":"${gen_uid(10)}" }

在上面的data_interface里面可以看到,${''}的樣式被我解析成了一個函數(shù),然后用eval來執(zhí)行得到結(jié)果。但是如果我直接執(zhí)行肯定是報錯的,因為此時的上下文里面沒有g(shù)en_uid這個對象,我的解決方法是單獨創(chuàng)建了一個py文件,在里面導入或定義需要用到的函數(shù)等,然后在conftest.py里面動態(tài)導入,通過vars拿到該模塊屬性,一一添加到builtins.dict里面去,問題便解決了。

custom_functions = vars(importlib.import_module('custom_functions'))
for k,v in custom_functions.items():
    if not k.startswith('__'):
        if k not in builtins.__dict__:
            builtins.__dict__.update({k:v})

持續(xù)集成

我在用例里通過python-allure-adapter集成了allure測試報告,然后在Jenkins里面通過構(gòu)建,通過allure插件生成報告,extended email生成郵件發(fā)送都是老生常談,就不細說了。
自動化測試最好有一套單獨的環(huán)境,如果服務(wù)很多的話,手動部署是很麻煩的事情,Jenkins也能很方便的幫助做自動化部署的事情,Jenkins ssh配置好后寫個部署腳本也是比較簡單的事情,也不細說了。

后續(xù)計劃

測試框架做好后,對接下來做好自動化測試工作具有很大的意義,后續(xù)的改進計劃包括:

  • 增加UI測試工具

  • 增加部分忽略的功能,滿足線上運行要求

  • 用例開發(fā)帶來的適配問題

  • web化

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Spring Boot高級 內(nèi)容概要 一、Spring Boot與緩存 二、Spring Boot與消息 三、Sp...
    順毛閱讀 448評論 0 2
  • operator.itemgetter函數(shù): import operator >>> help(operator....
    淡水t海邊閱讀 95評論 0 0
  • (一)、啟動服務(wù)器 (二)、創(chuàng)建數(shù)據(jù)庫表 或 更改數(shù)據(jù)庫表或字段 Django 1.7.1及以上 用以下命令 1....
    夏天夏星閱讀 5,940評論 0 17
  • PRIVACY POLICY JUNFENG Inc and Third Party Software Appli...
    lasibao閱讀 265,556評論 0 0
  • Express 應(yīng)用使用回調(diào)函數(shù)的參數(shù): request 和 response 對象來處理請求和響應(yīng)的數(shù)據(jù)。app...
    菜鳥億個閱讀 133評論 0 0

友情鏈接更多精彩內(nèi)容