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

flystar · 2018年08月24日 · 最后由 xuexue 回复于 2020年02月27日 · 2224 次阅读

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

框架设计

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配置好后写个部署脚本也是比较简单的事情,也不细说了。

#### 后续计划

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

共收到 5 条回复 时间 点赞

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

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

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

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

MyDict()是什么数据类型呢,一般会放置哪些信息在里面呢

需要 登录 後方可回應,如果你還沒有帳號按這裡 注册