前言
之前看过相关的测试数据准备的文章,坦白说,看完之后,能记住只有2个:
- api准备数据;
- 数据库插入;
而最近,同学恰好也问到这问题:
当时回复就如上面的答复,现在回头想想,的确没想到好的方案,在脑海里,有一个所谓的"终极方案",就是读取接口文档,自动生成测试数据,理论上可行,但一直没去做,懒;
有啥办法
做过单元/接口测试的同学都知道,其中有一个环节就是测试数据准备
,而这一步是不可或缺的一步,也是需要花费大量时间投入的一步;
测试接口前就必须准备好该接口需要处理的数据,而数据又有可能依赖其他的数据,这就提高了准备数据的复杂度与难度;
那到底有什么办法?
- 基于GUI操作生成测试数据;
- 基于API调用生成测试数据;
- 基于数据库操作生成数据;
- 基于第三方库自建数据;
- 结合多种方式生成数据;
- 导入线上/测试数据;
GUI操作生成数据
基于GUI操作生成数据,是指使用自动化脚本或者人工执行业务流程生成数据。
现在需要测试登录功能,这就需要准备一个已经注册的用户,此时,可以通过GUI操作来创建一个用户(无论是手工还是自动化脚本),然后再用新建的用户测试登录;复制代码
这种方式简单直接,并且数据来源于真实的业务流程,一定程度保证了数据的准确性。
然而,缺点也很明显:
- 创建数据的效率低:每次的GUI操作只生成一条数据,并且操作非常耗时;
- 易封装:通过GUI操作生成数据的过程,其实就是在开发自动化case的过程,加大了工作量;
- 成功率不高:GUI的变化直接会导致数据生成失败;
- 引入了其他依赖:数据生成成功的前提,依赖于业务流程的正确性。
一般情况下,基本不会使用这种方式生成数据,除非没有其他更好的方式来创建可靠的数据。
不过,操作GUI生成数据是其他两种方式API调用和操作数据库的基础,因为可以知道一条测试数据创建的过程;
API调用生成数据
实际上使用GUI操作生成数据,本质上就是在调用API。
使用GUI界面注册用户时,实际上调用了createUser的API。复制代码
要注意的是,一次GUI操作可能调用了多个API,一般情况下,都把调用API生成数据的过程封装成数据准备函数;
也许会有疑问,到底要怎样才知道调用了哪些api?
- 直接问开发;
- 看源码;
- 模拟一遍,抓包;
这种方式优势在于:
- 保证数据准确性;
- 执行效率高;
- 封装成函数更灵活可控;
这种方式也不是十全十美,缺点在于:
- 并不是所有数据创建都有对应的API;
- 业务很复杂的情况下,需要调用多个API,增加复杂性;
- 需要海量数据时,即使使用了并发,效率也尽如人意;
- API依赖性;
因此,业界往往还会通过数据库的CRUD操作生成测试数据;
数据库操作生成数据
数据库生成数据一般做法是,将创建数据需要的SQL封装成函数,然后再进行调用。
这样就能直接通过数据库操作,将测试数据插入系统数据库。
还是用户登录,直接往userTable和userRoleTable两张表插入数据,即可完成注册。复制代码
这样做的前提是,需要知道修改了哪些数据库业务表;
这种方式的优势在于:
- 效率高,能在短时间内生成批量数据;
缺陷也很明显:
- 维护成本高,当涉及到很多张表的时候,封装的数据准备函数就需要大量时间来维护;
- 数据容易缺失,一个业务操作设计到的表往往不止一张,容易遗漏;
- 健壮性差,SQL语句变化时,封装的函数必须实时同步更新,维护成本很高;
第三方库生成数据
这种方式就比较直接,直接使用代码封装成函数生成数据。
拿python为例,可以自己结合random()之类的函数随机生成数据,还可以使用faker这样的第三方库:
from faker import Factoryfake = Factory().create('zh_CN')def random_phone_number(): '''随机手机号''' return fake.phone_number()def random_name(): """随机姓名""" return fake.name()def random_address(): """随机地址""" return fake.address()def random_email(): """随机email""" return fake.email()复制代码
结合多种方式来生成数
实际上,实际应用中都采用多种方式相结合的方式生成测试数据。
最典型的应用场景是,先通过API调用或者第三方库生成基础的测试数据,然后使用数据库的CRUD操作生成符合特殊需求的数据。
比如:
# 注册新用户并进行绑卡1. 使用封装的faker库随机生成姓名,手机号,邮箱等信息,并调用createUser API进行注册;2. 查询userTableb表获得用户名,然后调用bindCard API实现绑卡。其中,bindCard API中使用的userID即为上一步createUser API中产生的用户ID;3. 如有需要,通过数据库操作更新其他信息。复制代码
以上就是一个常用的创建测试数据的过程;
当然也可以在测试用例执行前通api创建数据,执行后清除数据的方式;
导入线上/测试数据
这个就是直导入线上/测试数据,优点是更加贴近用户,出现问题,可直接模拟,但一般都不提供这种方式,就不细说了;
数据创建时机
准备测试数据的时候,都有什么痛点?
- 耗时长,导致用例执行时间长;
- 执行测试时可能会出现原先数据被修改而无法复用的情况;
- 环境不稳定导致数据异常;
正因上面的原因,数据准备不能随时进行,因为,创建时机很重要;
实时创建
指测试用例时实时创建需要的测试数据,所有数据都必须在测试用例开始前实时准备,比如api方式;
优点:
- 不依赖测试用例外的数据;
- 保证数据的准确性和可控性;
缺点:
- 耗时长;
- 维护成本高;
- 数据存在复杂关联性;
- 依赖性;
提前创建
指在准备测试环境时就预先将需要的数据提前准备好,比如数据库插入;
优点:
- 节省用例执行时间;
- 不会因为环境问题导致数据无法创建;
缺点:
- 脏数据;
所谓的脏数据,是指数据在被实际使用前,已经被进行了非预期的修改;
而脏数据可能的来源是:
- 被其他使用,并修改了状态;
- 手工测试时不小心修改了数据;
- 调试过程修改了数据;
如何解决:
- 维护一份数据,执行后复原;
- 数据分类,不同数据区段来分配使用对象,比如0-100是A团队,100-200是B团队,通过流程保证;
该方式不适用于只能一次性使用的场景;
如何抉择
稳定不常变化的数据,或是公用数据,建议使用提前创建的方式(数据库),一般来说,适用于接口测试环节;
只能一次性使用,或经常变化的数据,又因环境不一致,建议使用实时创建的方式(API);
一般来说,接口测试就是用实时创建的方式,用例执行前构造数据,执行后清除数据,这样就能尽可能保证用例之间相互不影响,也避免脏数据的产生;复制代码
适用场景
一般来说,接口测试,都用
数据准备的方法
大多数采用的方法
大多数企业采用的方法就是,将测试数据准备的操作封装成函数;
举个例子:
def post(self,url,data,code,msg): resp= requests.post(readconfig('url', 'url')+url, data=data) self.assertEqual(200, resp.status_code) self.assertEqual(code, resp.json()['code']) self.assertIn(msg, json.dumps(resp.json()['msg']).decode("unicode-escape")) return resp复制代码
这样就可以把数据创建相关操作封装成函数,业务方只需要直接调用函数即可;
但,致命的问题是,参数非常多,也非常复杂;如上面的例子,就需要4个参数,而实际工作,可能会多达十几个;
而绝大部分情况下,只需要个别参数,其他参数可以使用默认值即可;
那样,代码就会演变成这样:
def xx(A='',B=True,C="xx"): ... return jbtest(A,B,C)def xxx(): ... return jbtest(A,B,C)def xxxx(A=''): ... return jbtest(A,B,C)复制代码
这样封装,对于一些常用的数据组合,可以通过一次调用就生成需要的数据;
对于不常用的数据,可以直接调默认的函数来创建,这样就可以更灵活处理;
但是,也有弊端:
- 参数越多,封装的函数数量随之增加,最终可能演变成上百个函数;
- 可维护性差,底层函数会影响所有封装的函数,动一发而牵全身;
大公司怎么玩
既然上面的方法有问题,那能否优化下?同时,大公司怎么玩?
想想老东家,谈不上是小公司,但基本也是用上面的方式,前段时间看了茹炳晟老师也有提及到这点,就是引入Builder Pattern
封装方式;
Builder Pattern
基本概念
到底什么是Builder Pattern
,翻译过来是建造者模式,目的就是将一个对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示;
没看懂?直接来例子:
不引入Builder Pattern,买车的条件有产地,座位数,油耗:Car.buy(Country="",Seats="",FuelConsumption="")随着条件越多,传参随之增加;引入Builder Pattern:例子1:买一辆车,没其他要求:Car.buy();例子2:买一辆车,中国产的:Car.withBuildCountry("China").buy();例子3:买一辆车,中国产的,7座的:Car.withBuildCountry("China").withSeats("Seven").buy();复制代码
明白了吗?核心就是在用户不知道对象的建造过程和细节的情况下,可以直接创建对象;
这3个例子,可以反向说明解决了什么问题:
- 方便用户创建对象时,不需要知道实现过程,只需要给出指定对象的类型和内容即可;
- 代码复用性 & 封装性,将构建过程和细节进行封装;
1. 工厂(建造者模式):负责制造汽车(组装过程和细节在工厂内) 2. 汽车购买者(用户):你只需要说出你需要的型号(对象的类型和内容),然后直接购买就可以使用了 (不需要知道汽车是怎么组装的(车轮、车门、发动机、方向盘等等))复制代码
结构图
组成
建造者模式包含如下角色: Builder:抽象建造者 ConcreteBuilder:具体建造者 Director:指挥者 Product:产品角色
职责
角色 | 职责 |
---|---|
Builder | 创建一个Product对象的各个部件指定抽象接口 |
ConcreteBuilder | 实现Builder的接口以构造和装配该产品的各个部件,定义并明确它所创建的表示,提供一个检索产品的接口; |
Director | 构造一个使用Builder接口的对象; |
Product | 表示被构造的对象,包含定义组成部件的类; |
换种说法
- 指挥者(Director)直接和客户(Client)进行需求沟通;
- 沟通后指挥者将客户创建产品的需求划分为各个部件的建造请求(Builder);
- 将各个部件的建造请求委派到具体的建造者(ConcreteBuilder);
- 各个具体建造者负责进行产品部件的构建;
- 最终构建成具体产品(Product)。
优点
- 将一个对象分解为各个组件,相对独立,不受影响;
- 将对象组件的构造封装起来,客户端不需要知道内部细节;
- 可以控制整个对象的生成过程;
缺点
- 对不同类型的对象需要实现不同的具体构造器的类,这可能大大增加类的数量;
- 使用范围受限制,只适用于产品组成功能相似的产品,即可复用;
什么时候适用建造者模式
- 生成的产品对象有复杂的内部结构;
- 生成的产品对象的属性相互依赖,建造者模式可以强迫生成顺序;
- 在对象创建过程中会使用到系统中的一些其它对象,这些对象在产品对象的创建过程中不易得到;
例子1-微信公众号消息推送
相信大家在使用微信时,也都收到过消息推送吧,来看看官网提供的一个实例:
{ "touser":"OPENID", "template_id":"ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY", "url":"http://weixin.qq.com/download", "miniprogram":{ "appid":"xiaochengxuappid12345", "pagepath":"index?foo=bar" }, "data":{ "first":{ "value":"恭喜你购买成功!", "color":"#173177" }, "keynote1":{ "value":"巧克力", "color":"#173177" }, "keynote2":{ "value":"39.8元", "color":"#173177" }, "keynote3":{ "value":"2014年9月22日", "color":"#173177" }, "remark":{ "value":"欢迎再次购买!", "color":"#173177" } }}复制代码
具体参数请自行到公众号开发平台查询,这里思考的是,怎么设计通用模板?
方法很多,但是这里给出建造者模式的做法,创建builder类(为了方便,去掉了miniprogram参数)::
# -*- coding: utf-8 -*- from collections import OrderedDictimport json # 模版中“data”节点的各个元素的数据结构class Metadata: def __init__(self, value, color): self.value = value self.color = color # 微信消息的建造器class MessageBuilder: __contentDict = OrderedDict() # 定义整个模版的数据结构,保持添加的顺序 __dataDict = OrderedDict() # 定义data节点的数据结构,保持添加的顺序 __dataNoteNext = 1 # data节点要添加的下一个元素的序号 def __init__(self, touser, template_id, url): self.__contentDict['touser'] = touser self.__contentDict['template_id'] = template_id self.__contentDict['url'] = url self.__contentDict['data'] = self.__dataDict def add_first_data(self, value, color): data = Metadata(value, color) self.__dataDict['first'] = data return self def add_remark_data(self, value, color): data = Metadata(value, color) self.__dataDict['remark'] = data return self def add_note_data(self, value, color): data = Metadata(value, color) self.__dataDict['keynote' + str(self.__dataNoteNext)] = data self.__dataNoteNext += 1 return self def build(self): # 为打印出来看的方便,这里将json序列化后的结果缩进2个空格,并且不把中文转为unicode return json.dumps(self.__contentDict, default=lambda o: o.__dict__, indent=2, ensure_ascii=False)复制代码
有两点要说明下:
- 建造者内部的字典采用OrderedDict,是为了保持顺序与微信示例一致;
- 建造者每个方法都返回了本对象的引用;
建造者有了,就来生成消息吧,想起上几天fc的通知:
模拟作如上两条微信消息:
if __name__ == '__main__': pickup_builder = MessageBuilder('jb', 'template_id_pickup', '') \ .add_first_data('您有一个快递在蜂巢柜里等你来取哦!', '#173177') \ .add_note_data('123456', '#173177') \ .add_note_data('jb快递', '#173177') \ .add_note_data('789456123', '#173177') \ .add_note_data('15914255XXX', '#173177') \ .add_note_data('广州', '#173177') \ .add_remark_data('元宵节快到了,人不在家,也要把爱寄回家~', '#173177') print('生成取件通知微信消息') print(order_builder.build()) print() takeout_builder = MessageBuilder('user222222', 'template_id_takeout', '') \ .add_first_data('您的包裹已被取出啦', '#173177') \ .add_note_data('jb快递', '#173177') \ .add_note_data('78954', '#173177') \ .add_note_data('15914255XXX', '#173177') \ .add_note_data('广州', '#173177') \ .add_remark_data('点击详情查看物流进度', '#173177') print('生成取出微信消息') print(send_builder.build())复制代码
这样看下来,是不是代码清晰多了,而且可复用,好像很不错的感觉~
例子2-组建身体
该例子来源于:
#!/usr/bin/env python# -*- coding:utf-8 -*-import abcclass Builder(object): __metaclass__ = abc.ABCMeta @abc.abstractmethod def create_header(self): pass @abc.abstractmethod def create_body(self): pass @abc.abstractmethod def create_hand(self): pass @abc.abstractmethod def create_foot(self): passclass Thin(Builder): def create_header(self): print '瘦子的头' def create_body(self): print '瘦子的身体' def create_hand(self): print '瘦子的手' def create_foot(self): print '瘦子的脚'class Fat(Builder): def create_header(self): print '胖子的头' def create_body(self): print '胖子的身体' def create_hand(self): print '胖子的手' def create_foot(self): print '胖子的脚'class Director(object): def __init__(self, person): self.person = person def create_preson(self): self.person.create_header() self.person.create_body() self.person.create_hand() self.person.create_foot()if __name__=="__main__": thin = Thin() fat = Fat() director_thin = Director(thin) director_fat = Director(fat) director_thin.create_preson() director_fat.create_preson()复制代码
上面类的设计如下图,
指挥者Director 调用建造者Builder的对象,具体的建造过程是在Builder的子类中实现的;
回到正文,理解完建造者模式
,突然发现,好像跟上面的封装概念相似的?
def xx(A='',B=True,C="xx"): ... return jbtest(A,B,C)def xxx(): ... return jbtest(A,B,C)def xxxx(A=''): ... return jbtest(A,B,C)复制代码
是的,Builder Pattern
也是封装方式,一般来说,会基于原有的封装再二次封装,这样的好处就是业务方无需关心内部逻辑,营造用的好爽的感觉,而Builder Pattern
内部还是使用api或者数据库的方式来创造数据,只是进行易用性封装而已;
对业务方来说,是用的爽,对于维护者来说,苦的一逼,详情请看上面的缺点,简单就是维护成本高,容易出现动一发而牵全身,一般来说,只有大厂才会做这事;
平台化
建造者模式是一种设计的思路,因此可适用于不同语言,但不同公司使用的语言不一样,有Java、Python、php等等,因此,同一套代码,不同环境,就不适用了;
因此,解决这问题的核心在于封装成api,并且结合GUI界面,做成平台的形式,也就是所谓的测试数据平台;
但目前来看,业界没看到类似开源的例子,可能都是内部使用;
憧憬
虽然创建数据越来越方便了,但每次都需要创建数据,部分可能还是重复数据;
能否创建前先搜索,如果有符合条件的数据,直接返回,没有再创建数据,这样的话,测试数据也会越来越庞大,便于平台化后的数据复用;
不过,这只是想而已,目前来说,jb自认没这能力写搜索逻辑,但一直希望,让自动化更自动;
比如接口测试,可以直接解析接口文档,根据每个字段类型,自动生成数据,这样连数据创建都不需要了;复制代码
小结
本文主要介绍数据创建相关的内容,大部分在数据创建,有两种方法:
- 直接使用暴露全部参数的数据准备函数,好处是灵活,弊端是每次调用前都需要准备大量数据;
- 使用封装函数,会更加灵活,但是可维护性差;
因此会引入建造者模式
的概念,本质上也是使用api跟操作数据库两种方式来创建数据,只是基于原来的封装再进行二次易用性封装,优点在于业务方可以快速生成需要的数据;
并且介绍了后续平台化的想法,以及个人的一些憧憬;
建造者模式不是万能的,依然对使用场景有限制,用的不好,就会导致易用性差的情况;
温馨提示,当用例执行完毕,需要把公共数据复原,尽可能减少对其他业务方的干扰;
如果需要记录使用的数据,可单独把测试过程的数据入库,以便后面出现问题后有记录复现跟进;
最后,谢谢大家~