最近入职了新公司,负责自动化测试相关的工作,那么首先当然是自动化测试平台的开发了。经过一个多月的奋战,到现在功能基本完成,结果还是比较满意和有成就感的,过程很受锻炼,其中的思考、经验、知识点、总结等,打算写个系列文章记录下来。
言归正传。
如上图,根据分层测试理论,单元测试、集成测试和系统测试的资源投入比例 70:20:10 是比较合理的。
单元测试一般会由开发自己覆盖,那么,作为自动化测试人员,关注点应该主要放在 service 层,UI 适当兼顾,所以,我的目标就是做一个好用的、能满足系统集成测试和 UI 测试的、方便接入持续集成等功能的测试平台,满足回归测试、线上监控等需求。
首先的问题是技术选型。
自动化测试的开发大概有两种模式。
第一种是用例和代码逻辑分离,用例基于某种模板生成文本文件,然后用某种转换的方式去驱动底层的代码执行完成测试。这种方式的优点在于,写出来的用例清晰易懂,合作分工,学习成本低,易于在团队推广等。我在上家公司就是采用这种方式基于 lettuce 开发的一个 BDD 框架,效果总的来说还不错,但它也有固有的缺点,最大的缺点在于不灵活;其次,其实对于代码开发人员来说,用例转换是一个多余的动作,实际上是加大了专门的做自动化测试的人员的工作成本,就我的实践而言,对于不会代码也不愿意去学代码的同学,无论怎么样变换形式,兴趣啊积极性啊等等其实很难被激发起来,工作关键在于兴趣和自觉,外力感觉作用不太大。
所以我打算这次选第二种方式,也就是纯代码开发的方式。关于这个问题,我也在网上搜了搜,发现大多数同学也是倾向于纯代码开发,尤其是老鸟,为了成为老鸟,这更坚定了我的选择。
方向确定了,接下来就是方案了。
在 Python 生态里,测试框架还是挺多的,unittest、nose 等我也用过,但是感觉功能偏少,扩展也不便,pytest 知道但没有实际用过,深入了解之后,发现就俩字,好用!无论是 fixture,自身,参数化等,还是配合 allure 生成测试报告,简洁优雅又强大,一如 Python,决定就选 pytest 了。
方案也确定后,便是设计,先上图。
根据我的经验总结,开个一个框架,大概可以分两步走。第一步,自底而上,主要是一些底层逻辑的实现,比如 http 客户端、log、异常等等;第二步,自上而下,主要是用例相关,比如设计用例的开发方式、用例的执行过程等等。可以看到,图中大致可以分为两个部分,工具集和用例,我在设计的时候思考了很多,只求在正式写用例时能写的爽。
下面就一些主要模块分别讲述下。
包括 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 是一个装饰器,在我的想法里,接口的配置和接口的执行是分开的,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})
# 执行HTTP请求
return http(**interface_info)
return wrapper
return decorator
关于接口的访问,我更倾向于将接口定义成本地的方法,这样用的时候直接调用就可以了。
一般来说,接口的定义形式都差不多,不过是一些参数的不同,如果一个个去写成方法定义,那么会重复写很多的样式代码,肯定是不可取的。我的解决方式是通过元类,该元类的作用是在创建类时自动将类属性转化为类方法。
class InterfaceMetaClass(type):
"""
接口配置类的元类,会自动将配置的类属性转化为同名静态方法
"""
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 参数就可以发送请求了,当然还有个要说明的点就是 url,可以看到图中 url 并没有域名、端口等,这样肯定是访问不通的。因为在测试的时候肯定要满足不同的环境需求,服务地址是动态的,因此 url 要动态拼接,拼接操作发生在请求对象发送请求之前,调用 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']])
域名等配置信息统一配置在配置文件里,代码运行时动态导入到名为 bxmat 的自定义内置变量里,拼接时便可以从中取值。
再往上到 services 一层,便是请求参数的处理。大多数的接口都会有很复杂的参数,在写用例时不可能每次都写一堆参数上去,因此,这一层主要是封装底层接口调用,暴露出参数信息,为参数化、数据驱动等做准备。
@staticmethod
def adpopup_changestatus(id=0,popupStatus=0):
"""
:param id:
:param popupStatus:
:return:
"""
arguments = locals()
return ActivitiesAPI.adpopup_changestatus(**arguments)
数据库操作以 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实例,然后创建类对象继承自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):
"""
动态生成数据库表映射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
}
)
以上算是底层工具,为了方便自动化测试设计和用例开发,我用这些工具结合 pytest 做了进一步的封装。
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 的方式。第一种是通过生成器,这种方式简洁优雅,但是如果有返回值时,因为是生成器,取值时要通过 next(users),当在 pytest.mark.parametrize 中使用 next(users) 会造成 stopIteration 异常;并且一旦 yield 前面的代码报错,teardown 是不会执行的。
@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 函数,这种方式会强制执行,不管前面的代码是否报错
@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'])])
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)
在数据驱动测试时,我希望有一个统一的数据接口来管理测试数据,解析它们并往 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,接下来便是用例部分。
至此,我们已经可以写出这样的测试用例。用例的数据和代码都可以根据实际测试场景开发,增加 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()})
比如自定义一个测试环境的命令行参数,这在测试时很有用,这样测试时便可以方便的指定测试环境,然后通过 request.config.option 来取环境参数,从而导入相应的配置信息.
def pytest_addoption(parser):
"""
增加测试环境命令行参数
: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):
"""
自定义的测试环境变量
:param request:
:return:
"""
env = request.config.getoption("--te")
return env
还有个问题,就是在写测试数据时,如果我们要构造一些动态生成的字段值,典型的比如 uid 等,手工写肯定是很蠢的事情,但在文本文件里面怎么做呢?这里我借鉴了模板语法
```
{"name":"${gen_uid(10)}" }
在上面的data_interface里面可以看到,${''}的样式被我解析成了一个函数,然后用eval来执行得到结果。但是如果我直接执行肯定是报错的,因为此时的上下文里面没有gen_uid这个对象,我的解决方法是单独创建了一个py文件,在里面导入或定义需要用到的函数等,然后在conftest.py里面动态导入,通过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})
#### 持续集成
我在用例里通过python-allure-adapter集成了allure测试报告,然后在Jenkins里面通过构建,通过allure插件生成报告,extended email生成邮件发送都是老生常谈,就不细说了。
自动化测试最好有一套单独的环境,如果服务很多的话,手动部署是很麻烦的事情,Jenkins也能很方便的帮助做自动化部署的事情,Jenkins ssh配置好后写个部署脚本也是比较简单的事情,也不细说了。
#### 后续计划
测试框架做好后,接下来就是自动化方案设计和用例设计编写了,广告业务比较复杂,测试链条比较长,通过在框架层面分层设计,已经将用例编写的复杂度降到最低了,再接再厉吧