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