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

最近入職了新公司窍蓝,負責自動化測試相關的工作腋颠,那么首先當然是自動化測試平臺的開發(fā)了。經過一個多月的奮戰(zhàn)吓笙,到現在功能基本完成淑玫,結果還是比較滿意和有成就感的,過程很受鍛煉面睛,其中的思考絮蒿、經驗、知識點叁鉴、總結等土涝,打算寫個系列文章記錄下來。
言歸正傳幌墓。

框架設計

image

如上圖但壮,根據分層測試理論,單元測試常侣、集成測試和系統測試的資源投入比例70:20:10是比較合理的蜡饵。
單元測試一般會由開發(fā)自己覆蓋,那么胳施,作為自動化測試人員溯祸,關注點應該主要放在service層,UI適當兼顧舞肆,所以焦辅,我的目標就是做一個好用的、能滿足系統集成測試和UI測試的椿胯、方便接入持續(xù)集成等功能的測試平臺筷登,滿足回歸測試、線上監(jiān)控等需求压状。?
首先的問題是技術選型仆抵。
自動化測試的開發(fā)大概有兩種模式。
第一種是用例和代碼邏輯分離种冬,用例基于某種模板生成文本文件镣丑,然后用某種轉換的方式去驅動底層的代碼執(zhí)行完成測試。這種方式的優(yōu)點在于娱两,寫出來的用例清晰易懂莺匠,合作分工,學習成本低十兢,易于在團隊推廣等趣竣。我在上家公司就是采用這種方式基于lettuce開發(fā)的一個BDD框架摇庙,效果總的來說還不錯,但它也有固有的缺點遥缕,最大的缺點在于不靈活卫袒;其次,其實對于代碼開發(fā)人員來說单匣,用例轉換是一個多余的動作夕凝,實際上是加大了專門的做自動化測試的人員的工作成本,就我的實踐而言户秤,對于不會代碼也不愿意去學代碼的同學码秉,無論怎么樣變換形式,興趣啊積極性啊等等其實很難被激發(fā)起來鸡号,工作關鍵在于興趣和自覺转砖,外力感覺作用不太大。
所以我打算這次選第二種方式鲸伴,也就是純代碼開發(fā)的方式府蔗。關于這個問題,我也在網上搜了搜挑围,發(fā)現大多數同學也是傾向于純代碼開發(fā)礁竞,尤其是老鳥糖荒,為了成為老鳥杉辙,這更堅定了我的選擇。
方向確定了捶朵,接下來就是方案了蜘矢。
在Python生態(tài)里,測試框架還是挺多的综看,unittest品腹、nose等我也用過,但是感覺功能偏少红碑,擴展也不便舞吭,pytest知道但沒有實際用過,深入了解之后析珊,發(fā)現就倆字羡鸥,好用!無論是fixture忠寻,自身惧浴,參數化等,還是配合allure生成測試報告奕剃,簡潔優(yōu)雅又強大衷旅,一如Python捐腿,決定就選pytest了。
方案也確定后柿顶,便是設計茄袖,先上圖。


image

根據我的經驗總結嘁锯,開個一個框架绞佩,大概可以分兩步走。第一步猪钮,自底而上品山,主要是一些底層邏輯的實現,比如http客戶端烤低、log肘交、異常等等;第二步扑馁,自上而下涯呻,主要是用例相關,比如設計用例的開發(fā)方式腻要、用例的執(zhí)行過程等等复罐。可以看到雄家,圖中大致可以分為兩個部分效诅,工具集和用例,我在設計的時候思考了很多趟济,只求在正式寫用例時能寫的爽乱投。
下面就一些主要模塊分別講述下。

httpManager

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

def__call__(self, *args, **kwargs):
        """
       1.調用請求客戶端處理請求
       2.調用響應處理器處理響應結果媳纬,返回
       :param args: 請求參數
       :param kwargs: 請求參數
       :return: 請求結果
       """
        combination_url(kwargs)
        arguments=kwargs.get('data')orkwargs.get('params')orkwargs.get('json')
        api=kwargs.get('url')
        # 將參數值轉為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('請求參數: %s',arguments)
                try:
                        return http_response(response, api, arguments)
                except (APIReusltIsNoneError, APIResponseError) as e:
                        self.logger.exception(e)
                        return False

主要是實現了call特殊方法双肤,這樣只需要實例化一個請求對象,通過傳入不同參數钮惠,而完成不同的請求茅糜。
httpResponse同樣實現了call方法,主要是解析response萌腿,一些特殊的接口可以在這里集中處理限匣。
interface是一個裝飾器,在我的想法里,接口的配置和接口的執(zhí)行是分開的米死,interface起到的是一個整合的作用锌历。當然,這里要搭配接口定義來講峦筒。

def interface(**kw):
        """
       接口裝飾器究西,用于定義服務端的接口訪問
       :param kw: 接口參數信息字典
       :return: 接口請求返回值
       """
        # 獲取接口輸入信息
        interface_info=kw
        def decorator(f):
                @wraps(f)
                def wrapper(*args, **kwargs):
                        # 獲取實際接口請求參數
                        actual_args=f(*args,**kwargs)
                        # 將請求參數合并進接口請求信息中
                        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

關于接口的訪問,我更傾向于將接口定義成本地的方法物喷,這樣用的時候直接調用就可以了卤材。
一般來說,接口的定義形式都差不多峦失,不過是一些參數的不同扇丛,如果一個個去寫成方法定義,那么會重復寫很多的樣式代碼尉辑,肯定是不可取的帆精。我的解決方式是通過元類,該元類的作用是在創(chuàng)建類時自動將類屬性轉化為類方法隧魄。

class InterfaceMetaClass(type):
        """
        接口配置類的元類卓练,會自動將配置的類屬性轉化為同名靜態(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)

簡單地說,我會根據不同的服務接口定義不同的接口配置類购啄,并將該類的元類設置為InterfaceMetaClass襟企,然后在類屬性中配置接口信息,包括url狮含、method顽悼、params等,這些信息等同于requests庫中的請求參數信息辉川,會直接傳給requests做請求表蝙,完全不用做任何額外處理拴测。

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

如圖乓旗,如此我們對于一個接口的訪問是異常清晰的,跟填空題一樣集索,也很方便管理屿愚。
RedictAPI類便有了一個redict方法,調用時傳入params參數就可以發(fā)送請求了务荆,當然還有個要說明的點就是url妆距,可以看到圖中url并沒有域名、端口等函匕,這樣肯定是訪問不通的娱据。因為在測試的時候肯定要滿足不同的環(huán)境需求,服務地址是動態(tài)的盅惜,因此url要動態(tài)拼接中剩,拼接操作發(fā)生在請求對象發(fā)送請求之前忌穿,調用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ài)導入到名為bxmat的自定義內置變量里,拼接時便可以從中取值郊愧。

services

再往上到services一層朴译,便是請求參數的處理。大多數的接口都會有很復雜的參數属铁,在寫用例時不可能每次都寫一堆參數上去眠寿,因此,這一層主要是封裝底層接口調用焦蘑,暴露出參數信息澜公,為參數化、數據驅動等做準備喇肋。

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

dbManager

數據庫操作以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基礎db對象甚侣,通過實例化該對象得到db實例,然后創(chuàng)建類對象繼承自db.Model间学,便可以對相應表進行操作
       :param kwargs: dialect 數據庫類型
                       driver 數據庫驅動
                       user 用戶名
                       password 用戶密碼
                       host 數據庫地址
                       port 端口
                       database 數據庫名
       """
        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)生成數據庫表映射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
       }
   )

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

fixtures

fixture是pytest測試框架的最大亮點之一嘿悬,它的概念很模糊实柠,難以準確描述,本質上只是一個被pytest.fixture裝飾的函數善涨,但是pytest的運行機制為這個函數賦予了神奇的魔力窒盐,它既可以去做setup、teardown這樣的事情钢拧,又可以被當做數據容器傳值蟹漓。
比如,生成用戶id的場景源内,在其他fixture中使用users就可以直接使用該函數返回值葡粒。

@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函數糯彬,這種方式會強制執(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提供的數據驅動測試功能,非常方便撩扒,通過pytest.mark.parametrize的裝飾似扔,可以方便的向測試方法傳入參數化數據。
比如這樣搓谆,add_activity接口已經被改造成了非常方便做數據驅動測試炒辉,通過這樣的封裝,在用例層面泉手,便可以寫出簡潔的代碼

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

在數據驅動測試時黔寇,我希望有一個統一的數據接口來管理測試數據,解析它們并往pytest.mark.parametrize傳斩萌。

def data_interface(dir=None,file=None,parametrize=True):
    """
   測試數據統一接口
   :param dir: 測試數據目錄
   :param file: 測試數據文件名
   :param parametrize: 是否轉化為參數化的數據
   :return: 測試數據
   """
    if dir and file:
        data=file_load(dir,file)
        # 轉義測試數據中的特殊值缝裤,如${gen_uid(10)}
        pattern_function=re.compile(r'^\${([A-Za-z_]+\w*\(.*\))}$')
        def my_iter(data):
            """
            遞歸配置文件,根據不同數據類型做相應處理颊郎,將模板語法轉化為正常值
            :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憋飞,接下來便是用例部分。
至此姆吭,我們已經可以寫出這樣的測試用例榛做。用例的數據和代碼都可以根據實際測試場景開發(fā),增加case只需要往測試數據文件里面填數據就可以了猾编。

@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瘤睹,是根據約定大于配置的思想定義的,這個文件很有意思答倡,在它里面可以自定義擴展命令行參數,可以定義fixture而不需要在寫用例使用時導入等驴党,更重要的是還可以在里面做一些初始化的工作瘪撇。
像上面有提到一個bxmat的內置變量,它很關鍵。它的實現如下倔既,可以看到它被添加到了builtins的dict屬性字典里面恕曲,所以可以在代碼任何地方訪問到,算是個小魔法渤涌。

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

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

def pytest_addoption(parser):
    """
   增加測試環(huán)境命令行參數
   :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

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

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

在上面的data_interface里面可以看到,${''}的樣式被我解析成了一個函數居砖,然后用eval來執(zhí)行得到結果虹脯。但是如果我直接執(zhí)行肯定是報錯的,因為此時的上下文里面沒有gen_uid這個對象奏候,我的解決方法是單獨創(chuàng)建了一個py文件归形,在里面導入或定義需要用到的函數等,然后在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里面通過構建,通過allure插件生成報告狠轻,extended email生成郵件發(fā)送都是老生常談奸例,就不細說了。
自動化測試最好有一套單獨的環(huán)境向楼,如果服務很多的話查吊,手動部署是很麻煩的事情,Jenkins也能很方便的幫助做自動化部署的事情湖蜕,Jenkins ssh配置好后寫個部署腳本也是比較簡單的事情逻卖,也不細說了。

后續(xù)計劃

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

  • 增加UI測試工具

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

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

  • web化

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市盗迟,隨后出現的幾起案子坤邪,更是在濱河造成了極大的恐慌,老刑警劉巖罚缕,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件艇纺,死亡現場離奇詭異,居然都是意外死亡邮弹,警方通過查閱死者的電腦和手機黔衡,發(fā)現死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來肠鲫,“玉大人员帮,你說我怎么就攤上這事〉妓牵” “怎么了捞高?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長渣锦。 經常有香客問我硝岗,道長,這世上最難降的妖魔是什么袋毙? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任型檀,我火速辦了婚禮,結果婚禮上听盖,老公的妹妹穿的比我還像新娘胀溺。我一直安慰自己,他們只是感情好皆看,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布仓坞。 她就那樣靜靜地躺著,像睡著了一般腰吟。 火紅的嫁衣襯著肌膚如雪无埃。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天毛雇,我揣著相機與錄音嫉称,去河邊找鬼。 笑死灵疮,一個胖子當著我的面吹牛织阅,可吹牛的內容都是我干的。 我是一名探鬼主播始藕,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼蒲稳,長吁一口氣:“原來是場噩夢啊……” “哼氮趋!你這毒婦竟也來了伍派?” 一聲冷哼從身側響起江耀,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎诉植,沒想到半個月后祥国,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡晾腔,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年舌稀,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片灼擂。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡壁查,死狀恐怖,靈堂內的尸體忽然破棺而出剔应,到底是詐尸還是另有隱情睡腿,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布峻贮,位于F島的核電站席怪,受9級特大地震影響,放射性物質發(fā)生泄漏纤控。R本人自食惡果不足惜挂捻,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望船万。 院中可真熱鬧刻撒,春花似錦、人聲如沸耿导。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽碎节。三九已至捧搞,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間狮荔,已是汗流浹背胎撇。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留殖氏,地道東北人晚树。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像雅采,于是被迫代替她去往敵國和親爵憎。 傳聞我的和親對象是個殘疾皇子慨亲,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內容

  • Spring Boot高級 內容概要 一、Spring Boot與緩存 二宝鼓、Spring Boot與消息 三刑棵、Sp...
    順毛閱讀 373評論 0 2
  • operator.itemgetter函數: import operator >>> help(operator....
    淡水t海邊閱讀 75評論 0 0
  • (一)、啟動服務器 (二)愚铡、創(chuàng)建數據庫表 或 更改數據庫表或字段 Django 1.7.1及以上 用以下命令 1....
    夏天夏星閱讀 5,660評論 0 17
  • PRIVACY POLICY JUNFENG Inc and Third Party Software Appli...
    lasibao閱讀 264,779評論 0 0
  • Express 應用使用回調函數的參數: request 和 response 對象來處理請求和響應的數據蛉签。app...
    菜鳥億個閱讀 97評論 0 0