JB的测试之旅-测试数据的准备/构造

3,154 阅读17分钟

前言

之前看过相关的测试数据准备的文章,坦白说,看完之后,能记住只有2个:

  • api准备数据;
  • 数据库插入;

而最近,同学恰好也问到这问题:

image.png-36.3kB

当时回复就如上面的答复,现在回头想想,的确没想到好的方案,在脑海里,有一个所谓的"终极方案",就是读取接口文档,自动生成测试数据,理论上可行,但一直没去做,懒;

image.png-89.4kB

有啥办法

做过单元/接口测试的同学都知道,其中有一个环节就是测试数据准备,而这一步是不可或缺的一步,也是需要花费大量时间投入的一步;

测试接口前就必须准备好该接口需要处理的数据,而数据又有可能依赖其他的数据,这就提高了准备数据的复杂度与难度;

那到底有什么办法?

  • 基于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 Factory

fake = 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. 汽车购买者(用户):你只需要说出你需要的型号(对象的类型和内容),然后直接购买就可以使用了 
(不需要知道汽车是怎么组装的(车轮、车门、发动机、方向盘等等))

结构图

image.png-36.5kB

组成

建造者模式包含如下角色: 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 OrderedDict
import 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的通知:

image.png-88.1kB

模拟作如上两条微信消息:

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 abc

class 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):
        pass

class 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()

上面类的设计如下图,

image.png-99.5kB

指挥者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或者数据库的方式来创造数据,只是进行易用性封装而已;

image.png-252.7kB

对业务方来说,是用的爽,对于维护者来说,苦的一逼,详情请看上面的缺点,简单就是维护成本高,容易出现动一发而牵全身,一般来说,只有大厂才会做这事;

平台化

建造者模式是一种设计的思路,因此可适用于不同语言,但不同公司使用的语言不一样,有Java、Python、php等等,因此,同一套代码,不同环境,就不适用了;

因此,解决这问题的核心在于封装成api,并且结合GUI界面,做成平台的形式,也就是所谓的测试数据平台

但目前来看,业界没看到类似开源的例子,可能都是内部使用;

憧憬

虽然创建数据越来越方便了,但每次都需要创建数据,部分可能还是重复数据;

能否创建前先搜索,如果有符合条件的数据,直接返回,没有再创建数据,这样的话,测试数据也会越来越庞大,便于平台化后的数据复用;

不过,这只是想而已,目前来说,jb自认没这能力写搜索逻辑,但一直希望,让自动化更自动;

比如接口测试,可以直接解析接口文档,根据每个字段类型,自动生成数据,这样连数据创建都不需要了;

小结

本文主要介绍数据创建相关的内容,大部分在数据创建,有两种方法:

  • 直接使用暴露全部参数的数据准备函数,好处是灵活,弊端是每次调用前都需要准备大量数据;
  • 使用封装函数,会更加灵活,但是可维护性差;

因此会引入建造者模式的概念,本质上也是使用api跟操作数据库两种方式来创建数据,只是基于原来的封装再进行二次易用性封装,优点在于业务方可以快速生成需要的数据;

并且介绍了后续平台化的想法,以及个人的一些憧憬;

建造者模式不是万能的,依然对使用场景有限制,用的不好,就会导致易用性差的情况;

温馨提示,当用例执行完毕,需要把公共数据复原,尽可能减少对其他业务方的干扰

如果需要记录使用的数据,可单独把测试过程的数据入库,以便后面出现问题后有记录复现跟进;

最后,谢谢大家~

1-140R3154U8.jpg-9kB