BlueTest

测试工程师使用的标准测试库。包含接口、压力、UI测试相关一站式解决方案

要求

Python下载路径 https://www.python.org/downloads/

Request下载路径 https://pypi.org/project/requests/ or 使用pip命令 pip install requests

安装

最简单的安装方式就是使用``pip``命令,作为一个pythoner,``pip``是必备工具之一

pip install BlueTest
pip3 install BlueTest # 双python环境 python3 pip

试一下,傻瓜式的Demo

>>>import BlueTest
>>>BlueTest.test()        #接口基础测试DEMO
>>>BlueTest.presstest()   #接口压测DEMO
- INFO: 测试数据生成 .//srcdata//test.json.postman_collection
- INFO: postman转csv成功:./srcdata/test.csv
- DEBUG: CSV文件内容序列化成功:[{'Lv': '', 'Cname': '', ...
- INFO: log exceptionCheck: 普通请求 ...
- INFO: log exceptionCheck: ['date']为空 ...
- INFO: log exceptionCheck: ['date']不传 ...

项目结构

│  test.py
│
├─log
│      all.log
│      error.log
│
├─result
│      data.txt
│      Press_1.txt
│      Press_2.txt
│      resualt.csv
│      time.csv
│
└─srcdata
        test.csv
        test.json.postman_collection

test.py测试脚本,请自行创建

logresultsrcdata 3个目录由BlueTest自行生成,用户无需关心

log 日志文件夹,

  • all.log 全部日志,隔天会自动重建并归档,
  • error.log 错误日志

result 执行结果文件夹

  • data.txt 接口基本测试结果,
  • Press_x.txt 压力测试原始数据 ,
  • resualt.csv time.csv 压力测试统计后结果
srcdata 测试入口数据
  • test.json.postman.... POSTMAN导出文件 使用BluetTest.test()会自动创建一个demo,正式使用时需要用户自行添加
  • test.csv 根据 test.json.postman.... 生成的中间文件

PS:之所以使用csv格式为转换和统计压力测试结果,是为了兼容不同的操作系统。而且便于后期的图表生成

Table of Contents

Quickstart

接口测试,是这个测试脚手架被搭建的初衷。所以代码的没有做到小而美。 秉承着一贯的原则,还是先看我们的范例吧

范例

接口基础测试

postman_collection 测试数据放置于 ./srcdata/ 目录中

>>>import BlueTest
    # xxx为测试文件test.json.postman_collection 名称。BlueTest.initPostMan("test")
    # 初始化PostMan测试数据
>>>BlueTest.initPostMan("test")  #test.json.postman_collection->test.csv
>>> - INFO: postman转csv成功:./srcdata/test.csv
    #依赖test.csv执行接口测试
>>>BlueTest.testByCsvData("test")
>>> - DEBUG: CSV文件内容序列化成功:[{'Lv': '', 'Cname': '', ...
>>> - INFO: log exceptionCheck: 普通请求 ...
>>> - INFO: log exceptionCheck: ['date']为空 ...
>>> - INFO: log exceptionCheck: ['date']不传 ...
>>> - INFO: log extrasCheck: ['date'] 额外参数校验 ...

接口压力测试Demo1

import BlueTest,random
class pressTest(BlueTest.SoloPress): #继承压力测试基类BlueTest.SoloPress
    def setup(self):
        self.count = 单线程执行数
    def runcase(self): #重写runcase方法
        response = random.choice(["成功","失败"]) #设置模拟测试数据
        self.file_write(self.name, response, BlueTest.toolbox.responseAssert(response)) #结果记录
press = BlueTest.Press(线程数)
press.run(pressTest)  #执行测试
press.dataReduction() #统计、整理测试结果
>>> index:1, run:10% ,num:2
>>> index:2, run:10% ,num:2
>>>  ...

接口压力测试Demo2

区别于Demo1的地方在于这个例子使用到了由 postman->csv 的文件。将压力测试与接口测试的数据耦合到一起,可以实现统一管理。

csv2dict = BlueTest.Csv2Dict(path="./srcdata/test.csv") #加载测试数据 需要填入完整相对/绝对路径
csv_data = csv2dict.run()  #生成测试数据
apitest = BlueTest.apiTest(csv_data[0]) #实例化测试单体
class solopress(BlueTest.SoloPress):
    def setup(self):
        self.count = 单线程执行数
    def runcase(self):
        response = apitest.soloRequest()
        self.file_write(str(self.num),response,apitest.responseAssert(response))
press= BlueTest.Press(线程数)
press.run(solopress)
press.dataReduction()

PS:更多使用方法详见函数说明

DetailedSteps

在这里介绍如何使用 bluetest 一步一步完成工作。

首先让我们看下 bluetest 里的主要 .py 文件

  • logInit.py 日志相关
  • parm.py 配置参数
  • press.py 压测相关
  • toolbox.py 工具箱
  • dome_test.py 范例
  • core.py 接口基础测试相关
  • YApi2Csv.py YAPI 一键转换为csv

准备数据

我们从第一步开始,准备数据。 按照我们的约定,做接口测试的源数据可以是 postman 里download下来的数据。如下图所示:

_images/dtailedsteps_postman.png

这样我们就获得了最原始的数据 test.json.postman_collection 名字很长,这是postman规定的,请暂时忍受一下。

由于这个文件的可读性比较差,而且不利于维护和使用,所以需要生成一个中间文件,在这里选用的是 csv 格式。

csv 作为 BlueTest 的标准数据文件,为了支持这一实现方式。

还提供了 postman 文件, 标准 swagger ,标准 YAPI 一键转换为 csv 的功能

而且不用担心,如果你的数据源不是以上格式,在后面的详细介绍中,会有 ``csv``文件格式的详细说明。可根据说明,编写自己的测试数据一键转换功能。

BlueTest.initPostMan(name,result_path = "")

function initPostMan

我们的一贯命名方式就是,中国式英语 - 命名风格。 顾名思义,这个函数的功能是初始化 postman 的数据。 调用方式可以有以下几种

BlueTest.initPostMan(name = "文件名缩写")  #test.json.postman_collection 则只需要 initPostMan("test")
BlueTest.initPostMan(name = "文件完整路径") #完整的文件相对路径/绝对路径
BlueTest.initPostMan(name = "文件名缩写",result_path = "转换后文件路径")

result_path 不建议填写,不填写,会直接在 ./srcdata/ 文件生成与数据源同名的文件,例test.json.postman_collection-> test.csv

initPostMan是如何工作的呢?

它的主要工作其实其实是进行文件名处理。除此以外的大部分功能其实是由我们一个类 Class Postman2Csv 来处理的。

首先initPostMan会将不确定的文件名(缩写,完整路径,绝对路径等) 进行处理后,变为标准格式后。将工作交于 Class Postman2Csv

postman2csv = BlueTest.Postman2Csv(path,result_path="")
postman2csv.run()

Class Postman2Csv

PostMan文件转换为Csv所使用的类。除了一大堆为了处理各种各样奇怪情况的代码以外。主要的进行工作的函数有:

data = postman2csv.getData(Cookie=False)

在这里有个常见的单词 Cookie ,由于PostMan导出的文件时常会带有一大堆很不重要的Cookie内容。为了降低后期的结果整理压力。在这里,我们默认关闭了Cookie选项。毕竟的用户鉴权,很多都不在Cookie里了,不论是单独的 Token,Sign 键值,还是Session方式。都更加流行。

当然,如果你还需要这个功能,可手动开启。

回到原来的话题,这个函数的功能是,用来获取 .postman_collection 文件的内容。并输出标准一个漂亮的数组,数组的内容是一堆长得不是很讨人喜欢的字典键值。

这样,我们就对 .postman_collection 文件进行了漂亮的序列化。这些数据的形状就任我们揉捏了。揉捏好的数据,按照约定,我们将它们变成 csv 格式。这就使用到了下一个函数。

postman2csv.write2Csv(data)

write2Csv = = write to csv 。如果你还看不明白含义,那么不是你的英文太好。就是中文不太好。 写入文件为csv格式

除了枯燥的将之前我们生成的漂亮数组 data 一条一条写入文件外。还增加了一些标志。用来加强可读性。

_images/dtailedsteps_csv.png

从上图可以看出,除了一行比较啰嗦的title以外,主要的标志有: START , END

这两个标志位是用来规定每个测试用例的范围。

在这两个标志位以内就是一个测试用例,在这两个标志位以外的区域可以任由大家进行备注,而不影响测试用例。也算是在可读性和易读性之间的一种平衡。以上的工作搞定之后,如果你幸运的没有出现异常,那么测试数据的准备工作已经全部完成了

function initYApi2Csv

标准 YAPI 一键转换为 csv

BlueTest.initYApi2Csv(projects,csvname,apipath,api_user,api_pwd,project_url,login_path,user,pwd,tmp)
# projects 需要生成csv文件的项目id
# csvname 写入的csv文件名称
# apipath Yapi的域名
# api_user Yapi登录用户名
# api_pwd Yapi登录密码
# project_url 项目域名
# login_path 项目登录path
# user 项目登录用户名
# pwd 项目登录密码
# tmp 可能会缺少的path

生成的csv文件在``./srcdata``目录下,内容同BlueTest.Postman2Csv生成文件一致。

接口测试

由于每个人,每个部门,每个公司的业务需求千奇百怪,所以,作为一个通用性的库。故我们暂时不考虑这些特性的东西。先把共性的问题解决。比如 值为空 键值均为空 额外参数校验 参数长度校验

bluetest 对以上功能均做到了一键式的执行。改点配置,执行,你就能获得一堆漂亮(啰嗦)的测试数据。使用者的工作,就从一个一个机械化的填异常参数,变成了只要点一下。

如何进行测试,如何配置这些东西呢?我们一步一步来。

  • 首先确保数据准备好了,有一个生成的csv文件
  • 然后执行以下语句
BlueTest.testByCsvData(name,normal_test=True,mkpy=False,limit_check = False,extras_check=True,encode="",case_type="",counter=True,need=0)
# csv 名称(test.csv->testByCsvData("test") ) or 绝对路径/相对路径 (testByCsvData("./tmp/test.csv") )
# normal_test 基础测试
# mkpy实时生成单接口.py文件
# limit_check 参数长度校验
# extras_check额外参数校验
# case_type="HeartTest" 只进行普通请求校验
# need=n 从第n个需要执行的用例开始执行
  • 执行结束之后。恭喜你,做完了。去 ./result/data.txt 里看结果吧。
  • 想要自动分析执行结果,查看``./result/result_error.txt``,如执行结果内不包含”0x000000”,会写入到该文件,包含接口请求地址、返回code、http响应code、message信息、type错误类型等。

type错误类型

  • 错误信息不明确:返回数据为空
  • 服务不存在:http_code返回500
  • 缺少参数或参数不正确:参数问题
  • 业务性问题:由于业务特殊性造成,可忽略
  • 其他:404,网络异常,PHPerror,未知错误,接口异常等

收起你一脸蒙蔽的表情,没错。做完了。整理数据,去发测试报告吧!但是如何执行的,你肯定很好奇。我们再次一步一步来。

function testByCsvData

依据 csv 数据进行测试,一堆入参的含义就不赘述了。它的主要工作其实和 initPostMan 很相似。主要做的是入参标准化。之后实例化了 Class Csv2Dictclass Dict2Pyclass ApiTest

Class Csv2Dict 这是又一次做序列化的工作了。这次是把标准的中间文件 csv 处理为使用起来最舒服的字典类型。

>>>import BlueTest
>>>csv2dict = BlueTest.Csv2Dict("./srcdata/test.csv").run()
>>>for key,value in csv2dict[0].items():
>>>    print (key,":",value)
- DEBUG: CSV文件内容utf8序列化失败重试:'utf-8' codec can't decode ...
- DEBUG: CSV文件内容序列化成功:[{'Lv': ''....
Lv :
Cname :
Name : log
Describe : testapi
ResualPath : ./api/log
Method : POST
Url : https://www.test.com/action/api/log
Headers : {'Origin': 'https://www.TEST.net', 'User...
Data : {"date":"2018-11-04 10:21:06","action...
DataType : raw
UrlParams : {'requestID': 'Abac6b...
TestType :

以上是一个完整的测试数据,部分空的内容,是预留给你使用的,当然,理想的情况是不使用。那就说明, bluetest 已经满足你的需求了。

Class Dict2Py

大家在使用 postman 的时候,应该玩过 Generate Code 这个漂亮的功能,可以将内容一键转为各种你需要的语言代码。一个很好,很实用的功能。所以,我们很谦虚(无耻)的兼容(抄袭)了这个功能。这个类就是做这件事情的。

>>>import BlueTest
>>>csv2dict = BlueTest.Csv2Dict("./srcdata/test.csv").run()
>>>BlueTest.dict2Py(data = csv2dict[0]).mkpy()

执行后,我们获得了 ./result/api/log.py 可以发现地址和上面的某一条 ↓↓

ResualPath : ./api/log

↑↑ 一致。所以大家不用担心,自己的生成的文件会被推在一起,造成困扰。

Class ApiTest

接口测试的基类

>>>import BlueTest
>>>apitest = BlueTest.ApiTest(data)

还是一样,从csv里抽一条数据作为入参,来实例化基类。在基类的构造函数来里可以看到,里面对接口的一些测试标准进行了配置。而且一切的初始化都是基于 param.py ,详细的配置内容,请自行查看相关文件。

在实例化获得 apitest 后,众所周知,构造函数已经运行了。这个时候。我们的数据准备已经完成。 执行具体的接口校验工作的方法有 limitCheck (长度校验) exceptionCheck (空校验) extrasCheck (额外参数校验)。 校验的执行方式如下:

>>>csv2dict = BlueTest.Csv2Dict("./srcdata/test.csv").run()
>>>apitest.dataReduction(csv2dict[0])   #正常用法
>>>apitest.dataReduction(csv2dict[0],limitcheck=True,extras_check=True)   #进行部分校验的用法

至此。接口测试的活干完了。之后就是结果分析和统计了。相关内容将在结果分析相关章节里进行叙述。 为了便于大家使用。接口基础测试的完整代码如下:

>>>#确保该路径下,存在该文件 ./srcdata/test.json.postman_collection
>>>BlueTest.initPostMan("test")       #数据准备
>>>BlueTest.testByCsvData("test")     #测试执行

压力测试

关于接口,除了日常的接口测试外,还有时常遇到的压力测试。由于现在更多的服务器在云端,而且各个云服务提供商,都有非常好的系统/应用监控系统。故,我们暂时跳过了服务器的监听。这些,大家只要根据时间戳,找运维拉数据就行。大家也不用痛苦的在服务器上安装JmeterPlugins之类的工具来监听了。毕竟专业的事情交给专业的人,这样才能提高效率和更好的做好自己本职的工作。世上从来不存在高大全的系统。也不存在完美的人。

还是先来几个例子:

Demo1

>>>import BlueTest,random
>>>class PressTest(BlueTest.SoloPress):
        def runcase(self):
            response = random.choice(["成功","失败"])
            self.file_write(str(self.num), response, BlueTest.toolbox.responseAssert(response))
    press= BlueTest.Press(2) #线程数
    press.run(PressTest)
    press.dataReduction()

Demo2

>>>import BlueTest
>>> csv_data = BlueTest.Csv2Dict(path="./srcdata/test.csv").run()
    apitest = BlueTest.apiTest(csv_data[0])
    class PressTest(BlueTest.SoloPress):
        def runcase(self):
            response = apitest.soloRequest()
            self.file_write(str(self.num),response,b.responseAssert(response))
    press= BlueTest.Press(2) #线程数
    press.run(PressTest)
    press.dataReduction()

Demo3

>>>import BlueTest
>>> temp = ["id1", "id2", "id3"]   #①
    apitest = BlueTest.apiTest(csv_data[0])
    class PressTest(BlueTest.SoloPress):
        def setup(self):
            self.num = temp[self.index-1] #②
        def runcase(self):
            apitest.data[BlueTest.csv_parm.DATA]["ID"] = self.num #③
            response = apitest.soloRequest()
            self.file_write(str(self.num),response,b.responseAssert(response))
    press= BlueTest.Press(3) #线程数
    press.run(PressTest)
    press.dataReduction()

如果大家耐得住性子的话,会看出。这三个例子明显的区别。 Demo1 使用的是随机生成的假数据。 Demo2 使用的是csv里的第一个接口的数据 Demo3Demo2 的基础上,增加了一些自定义参数。

从实际使用的角度而言, Demo3 是我们再实际工作中最常使用到的。除了大部分的模板式代码以外。其实需要手动编写的主要部分是自定义数据的部分。 全部的代码大概3行。 ① 数据初始化 ,② 线程获初始化据数, ③ 执行前,赋值。

Demo大家看到了。除此以外, BlueTest 里,也自带了两个相关的demo

>>>import BlueTest
>>>BlueTest.presstest()
>>>BlueTest.pressTestByCsv()
end

presstest() 执行肯定不会出现问题的,因为数据是我们随机生成的。但是 pressTestByCsv() 如果出问题的话…..放心,不会是大问题,耐心点

Demo说完,我们开始一步一步介绍,到底是如何工作的

Class Press

大家可以理解为,这是一个多线程的盒子,它自动生成多线程(我们最讨厌的东西)。实例化这个类的时候。就直接确定了,产生的线程数

>>>import BlueTest
>>>BlueTest.Press(线程数)

除此之外,压力测试最重要的一点就是对执行数据的整理,因为这才是我们需要的。这才是最后测试报告里需要体现的内容。为此我们写了一个方法 dataReduction

function dataReduction

这是 Class Press 中用来进行数据整理的方法。入参默认不填,或者填入你的压测结果路径 Press_press.log。 数据是基于三个维度进行的整理:

  • 自然时间流失过程中,接口请求的效率
  • 所有请求的耗时
  • 请求的成功率
为了便于统计和整理。我们将原始数据里的毫秒级数据整合成了秒级的数据(请求耗时除外)。并且输出位表格格式 time.csvresualt.csv

time.csv

_images/dtailedsteps_timecsv.png

可以看到,左侧是根据请求耗时(毫秒)对请求进行的统计。当然,图中右边的图表需要大家使用excel手动生成。

resualt.csv

_images/dtailedsteps_resualtcsv.png

时间轴变成了秒,并增加了成功与失败的统计。

在不进行调优的情况下,作为客户端可以看到的大部分内容,都已经包含在 resualttime 中。服务端的数据,大家可以根据 resualt.csv 中的时间戳,从监控系统中获取相关服务端状态和日志。当然,建议大家还是骚扰相关管理员或者运维来获取相关内容。

function run 作为 Class Press 中的主执行函数,它的入参比较特别,是一个类 Class SoloPress 。具体这个类是做什么我们稍后再讨论,先假设,我们已经有这么一个类了。让我们看一下 function run 究竟干什么了。

>>>def run(self,solopress):
        ThreadList = []
        lock = threading.Lock()
        for i in range(1, self.num+1):
            t = solopress(lock,i)
            t.setup()
            ThreadList.append(t)
        for t in ThreadList:
            self.runSleep()
            t.start()
        for t in ThreadList:
            t.join()

代码很短,而且很常见,简单来说。就是根据线程数 self.num 来新建线程,并运行他们。关于 threading.Lock() 的相关内容就不再这里说了。毕竟,锁是一个很复杂的东西。

简单的总结一下 Class Press 就有一个用来新建多线程,执行。并最后进行数据整理的类。 下面介绍下,出现了好几次的 Class SoloPress

Class SoloPress

从所继承的父类可以看出来 Class Press 继承的是object。 Class SoloPress 继承的是 threading.Thread。由此也可以看到,SoloPress与多线程相关,所以继承了线程控制类。按照正常的使用方法。我们需要重写部分函数函数:数据准备函数 function setup ,单次执行函数 funciton runCase 。这里要介绍的不是这些注定要被大家重写的函数。如果想了解用法请看上面的DEMO。而是主要介绍一下 Class SoloPress 本身为用户做了什么事。

function run

按照正常情况,run函数是类实例化后的主执行函数。所以在这里。我们做了单线程的接口调用。除了执行规定次数的 runCase 外,还进行了一些其他辅助类的工作,比如:线程执行的进度展示,执行数据的记录… 听上去是一些无关紧要的东西。但是却能提高很多用户体验,毕竟,谁也不想执行代码后,只能经过一段没有进度的等待,获得一堆没有规划好的数据。

异步压力测试

我们平时使用到的脚本思想,更多的还是同步的做法。例如`requests` 。我们请求一个接口的时候,需要发送请求,并且等待结果。在发送请求到接收请求的这段时间内,程序是被阻塞的。而且阻塞的成本根据情况而言,有可能会很大。

在做压力测试的时候,过多的线程数,会极度的消耗客户端的资源。使我们没有办法给服务器足够的压力。

为了解决这个问题,我们使用了异步的方式来进行了压力测试。
  • asyncio 一个容易让人不想看下去的库。
  • aiohttp 由于上手难度最低的`requests`并不支持异步的方式。所以用到这个库。

Demo

首先让我们看下Demo

 class Test(BlueTest.SoloPressAsync):
    async def setup(self, **kwargs):
        kwargs["data"]["code"] = await self.queue.get()
        return kwargs

    def newQueue(self):
        temp_list = []
        for i in range(0, 1000000):
            if len(str(i)) < 6:
                i = "0" * (6 - len(str(i))) + str(i)
            temp_list.append(str(i))
        queue = asyncio.Queue()
        [queue.put_nowait(temp) for temp in temp_list]
        self.queue = queue

if __name__ == '__main__':
    new = Test("http://hq.sinajs.cn/list=sh600001", "Get", headers={"requestTime": "test",
                                                                    "User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50 IE 9.0"},
               vuser=500, total_num=2000,
               data={"code": "", "phone": "13111111111"})
    new.mainrun()
    new.dataReduction()

if __name__ 之上是我们的测试类。可以看到我们继承了 BlueTest.SoloPressAsync 。然后重写了两个函数 setup , newQueue

  • setup 数据初始化
  • newQueue 建立数据队列

大家可以看到,以上的两个函数,都是为了压力时的数据定制化而存在。基本思想是,在 newQueue 里进行数据组装和入队。在 setup 里进行数据赋值与出队。如果不需要定制化数据,以上操作均不需要。

详细说明

Class SoloPressAsync

构造函数

def __init__(self,url,method,path=”./result/Press_press.log”,vuser=5,total_num=10,**kwargs):

由此可以看出,初始化它或者它的子类的时候。我们需要传入不少参数。

  • url 请求地址
  • method “GET” or “POST” or “OPTION” ….
  • path 结果文件,一般不需要更改
  • vuser 虚拟用户数,在不更改系统配置的情况下最大值为500
  • total_num 总请求数
  • **kwargs 用来传入http请求的其他参数,比如,data,params,headers… 与 requests 规则一致

所以一般的实例化方式如下:

new = Test(“请求地址”, “Get”,vuser=500, total_num=2000,
data={“code”: “”, “phone”: “”}, headers={“User-Agent”: “Test”})

暂时只做了测试执行前的,自定义数据。暂时没做多接口的场景模拟demo。 以下说明两个可能需要用户重写的函数。

newQueue

1    def newQueue(self):
2        temp_list = []
3        for i in range(0, 1000000):
4            if len(str(i)) < 6:
5                i = "0" * (6 - len(str(i))) + str(i)
6            temp_list.append(str(i))
7        queue = asyncio.Queue()
8        [queue.put_nowait(temp) for temp in temp_list]
9        self.queue = queue

我们来简单解释一下以上代码。2-6行,我们生成了一个自定义的数组。内容并没有特定的含义,大家可以根据实际的业务需要来新建自己的数据。 第7行,新建了一个asyncio的队列。 第8行,数组的值写入队列 第9行,赋值 self.queue

至此,我们再测试中需要使用到的数据已经搞定了。

setup

async def setup(self, **kwargs):
    kwargs["data"]["code"] = await self.queue.get()
    return kwargs

通过之前的 newQueue 组装好数据之后。我们需要在测试过程中使用到它。我们需要重写 setup 函数。首先这个函数需要添加异步的标签。并且入参是 **kwargs 是为了方便后面的基础函数直接使用数据进行异步请求。 kwargs["data"]["code"] = await self.queue.get() 相当于 new = Test(“请求地址”, “Get”,vuser=500, total_num=2000,

data={“code”: await self.queue.get(), “phone”: “”}, headers={“User-Agent”: “Test”})

当然,实际使用肯定不是这样。这行代码只是为了便于读者理解数据是传入了哪里。

执行 在大概理解以上内容后。我们就可以使用自己的自定义函数来执行异步的压力测试了。

new.mainrun()  #执行
new.dataReduction() #数据整理

这里的执行原始数据与press类的结果格式完全相同。数据整理结果也完全相同。

更多的场景DEMO,还没写

Demos

每一个测试工程师都是一位挑剔的处女座。 对于我们非常不漂亮的代码,如果你看完还没有想吐的话,一定不是一个合格的测试工程师。 为了避免被大家吐槽,为了降低大家看我们源码的恶心程度。在这里。我们讲写出各种使用DEMO。

Demo1:test

Markup

The following are examples of supported markup. On their own, these will not provide a datepicker widget; you will need to instantiate the datepicker on the markup.