自动化工具 系统集成自动化测试框架开发

flystar · August 24, 2018 · Last by 徐旻 replied at December 20, 2018 · 4123 hits

最近入职了新公司,负责自动化测试相关的工作,那么首先当然是自动化测试平台的开发了。经过一个多月的奋战,到现在功能基本完成,结果还是比较满意和有成就感的,过程很受锻炼,其中的思考、经验、知识点、总结等,打算写个系列文章记录下来。
言归正传。

框架设计

image
如上图,根据分层测试理论,单元测试、集成测试和系统测试的资源投入比例70:20:10是比较合理的。
单元测试一般会由开发自己覆盖,那么,作为自动化测试人员,关注点应该主要放在service层,UI适当兼顾,所以,我的目标就是做一个好用的、能满足系统集成测试和UI测试的、方便接入持续集成等功能的测试平台,满足回归测试、线上监控等需求。​
        首先的问题是技术选型。
        自动化测试的开发大概有两种模式。
        第一种是用例和代码逻辑分离,用例基于某种模板生成文本文件,然后用某种转换的方式去驱动底层的代码执行完成测试。这种方式的优点在于,写出来的用例清晰易懂,合作分工,学习成本低,易于在团队推广等。我在上家公司就是采用这种方式基于lettuce开发的一个BDD框架,效果总的来说还不错,但它也有固有的缺点,最大的缺点在于不灵活;其次,其实对于代码开发人员来说,用例转换是一个多余的动作,实际上是加大了专门的做自动化测试的人员的工作成本,就我的实践而言,对于不会代码也不愿意去学代码的同学,无论怎么样变换形式,兴趣啊积极性啊等等其实很难被激发起来,工作关键在于兴趣和自觉,外力感觉作用不太大。
所以我打算这次选第二种方式,也就是纯代码开发的方式。关于这个问题,我也在网上搜了搜,发现大多数同学也是倾向于纯代码开发,尤其是老鸟,为了成为老鸟,这更坚定了我的选择。
        方向确定了,接下来就是方案了。
        在Python生态里,测试框架还是挺多的,unittest、nose等我也用过,但是感觉功能偏少,扩展也不便,pytest知道但没有实际用过,深入了解之后,发现就俩字,好用!无论是fixture,自身,参数化等,还是配合allure生成测试报告,简洁优雅又强大,一如Python,决定就选pytest了。
        方案也确定后,便是设计,先上图。
image
        根据我的经验总结,开个一个框架,大概可以分两步走。第一步,自底而上,主要是一些底层逻辑的实现,比如http客户端、log、异常等等;第二步,自上而下,主要是用例相关,比如设计用例的开发方式、用例的执行过程等等。可以看到,图中大致可以分为两个部分,工具集和用例,我在设计的时候思考了很多,只求在正式写用例时能写的爽。
        下面就一些主要模块分别讲述下。

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是一个装饰器,在我的想法里,接口的配置和接口的执行是分开的,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

apiManager

关于接口的访问,我更倾向于将接口定义成本地的方法,这样用的时候直接调用就可以了。
        一般来说,接口的定义形式都差不多,不过是一些参数的不同,如果一个个去写成方法定义,那么会重复写很多的样式代码,肯定是不可取的。我的解决方式是通过元类,该元类的作用是在创建类时自动将类属性转化为类方法。

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

再往上到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实例,然后创建类对象继承自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做了进一步的封装。

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的方式。第一种是通过生成器,这种方式简洁优雅,但是如果有返回值时,因为是生成器,取值时要通过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'])])

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,接下来便是用例部分。
至此,我们已经可以写出这样的测试用例。用例的数据和代码都可以根据实际测试场景开发,增加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配置好后写个部署脚本也是比较简单的事情,也不细说了。

#### 后续计划

测试框架做好后,接下来就是自动化方案设计和用例设计编写了,广告业务比较复杂,测试链条比较长,通过在框架层面分层设计,已经将用例编写的复杂度降到最低了,再接再厉吧

共收到 4 条回复 时间 点赞

目测到一个问题 httpRequest 无法支持 params + data 或 params + json 的组合请求。

不知道楼主对多格式多协议还有测试数据这块是如何考虑的?

为啥我觉得用例和逻辑代码分离比较好呢

有问题想请教下, 微信:lunamagic1978

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up