最近入職了新公司窍蓝,負責自動化測試相關的工作腋颠,那么首先當然是自動化測試平臺的開發(fā)了。經過一個多月的奮戰(zhàn)吓笙,到現在功能基本完成淑玫,結果還是比較滿意和有成就感的,過程很受鍛煉面睛,其中的思考絮蒿、經驗、知識點叁鉴、總結等土涝,打算寫個系列文章記錄下來。
言歸正傳幌墓。
框架設計
如上圖但壮,根據分層測試理論,單元測試常侣、集成測試和系統測試的資源投入比例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了。
方案也確定后柿顶,便是設計茄袖,先上圖。
根據我的經驗總結嘁锯,開個一個框架绞佩,大概可以分兩步走。第一步猪钮,自底而上品山,主要是一些底層邏輯的實現,比如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化