Apitest 接口自动化测试工具
推荐一款用类似 JSON 语法写接口测试的工具
特性
- 跨平台
- DSL
- 类 JSON,没有学习难度
- 编写简单,阅读容易
- 不要求编写者会编程
- 数据即断言
- 数据可访问
- 支持 Mock
- 支持 Mixin
- 支持 CI
- 支持 TDD
- 支持用户定义函数
- 跳过,延时,重试和循环
- 支持 Form,文件上传,GraphQL
安装
Apitest 工具是单可执行文件,不需要安装,放到PATH路径下面就可以直接运行
# linux curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-linux chmod +x apitest sudo mv apitest /usr/local/bin/ # macos curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-macos chmod +x apitest sudo mv apitest /usr/local/bin/ # npm npm install -g @sigodenjs/apitest
开始使用
编写测试文件 httpbin.jsona
{ test1: { req: { url: "https://httpbin.org/anything", query: { k1: "v1", }, }, res: { body: { @partial args: { "k1": "v2", // 注意,这儿应该是"v1", 我们故意写"v2"以测试 Apitest 的反应 }, url: "https://httpbin.org/anything?k1=v1", } } } }
执行如下命令测试接口
apitest httpbin.jsona
其结果如下
main test1 (2.554) ✘ main.test1.res.body.args.k1: v2 ≠ v1 { "req": { "url": "https://httpbin.org/anything", "query": { "k1": "v1" } }, "res": { "headers": { "date": "Thu, 17 Jun 2021 15:01:51 GMT", "content-type": "application/json", "content-length": "400", "connection": "close", "server": "gunicorn/19.9.0", "access-control-allow-origin": "*", "access-control-allow-credentials": "true" }, "status": 200, "body": { "args": { "k1": "v1" }, "data": "", "files": {}, "form": {}, "headers": { "Accept": "application/json, text/plain, */*", "Host": "httpbin.org", "User-Agent": "axios/0.21.1", "X-Amzn-Trace-Id": "Root=1-60cb63df-1b8592de3767882a6e865295" }, "json": null, "method": "GET", "origin": "119.123.242.225", "url": "https://httpbin.org/anything?k1=v1" } } }
Apitest 发现了 k1 的值异常 main.test1.res.body.args.k1: v2 ≠ v1 并打印错误,同时还打印了接口请求响应详情。
如果我们修改 main.test1.res.body.args.k1 值 v2 => v1 后再执行测试。
apitest httpbin.jsona
其结果如下
main test1 (1.889) ✔
Apitest 报告测试通过了。
原理
Apitest 执行测试文件时会加载全部测试用例,逐一执行,其执行过程可以描述为:根据 req 部分构造请求发送给服务器,收到响应后依据 res 校验响应数据,然后打印结果。
Apitest 中的用例文件格式是 JSONA。JSONA 是 JSON 的超集,减轻了一些 JSON 语法限制(不强制要求双引号,支持注释等),再添加了一个特性:注解。上面例子中的@partial就是注解。
为什么使用 JSONA ?
接口测试的本质的就是构造并发送req数据,接收并校验res数据。数据即是主体又是核心,而 JSON 是最可读最通用的数据描述格式。 接口测试还需要某些特定逻辑。比如请求中构造随机数,在响应中只校验给出的部分数据。
JSONA = JSON + Annotation(注解)。JSON 负责数据部分,注解负责逻辑部分。完美的贴合接口测试需求。
示例
下面的示例会用到一些注解,不明白的地方请查看README
全等校验
默认请求下,Apitest 进行全等校验。
- 简单类型数据(null,boolean,string,number)完全相等
- object 数据属性和属性值完全相等,字段顺序可以不一致
- array 数据元素长度和各元素完全相等,元素顺序也要一致
{ test1: { @client("echo") req: { any: null, bool: true, str: "string", int: 3, float: 0.3, obj: {a:3, b:4}, arr: [3,4], }, res: { any: null, bool: true, str: "string", int: 3, float: 0.3, obj: {a:3, b:4}, // obj: {b:4, b:3}, object 类数据字段顺序可以不一致 arr: [3,4], } } }
Apitest 保证:只有当实际接收到的 res 数据与我们用例中描述的 res 数据全等,测试才会通过。
数组校验技巧
Apitest 默认全等校验,而接口返回的 array 数据可能几十上百条,怎么办?
通常接口数据是结构化的,我们可以只校验数组第一个元素。
{ test1: { @client("echo") req: { arr: [ {name: "v1"}, {name: "v2"}, {name: "v3"}, ] }, res: { arr: [ @partial { name: "", @type } ], } } }
如果 array 数据的长度也很关键呢?
{ test1: { @client("echo") req: { arr: [ {name: "v1"}, {name: "v2"}, {name: "v3"}, ] }, res: { arr: [ @every [ @partial { name: "", @type } ], `$.length === 3`, @eval ], } } }
对象校验技巧
Apitest 默认全等校验,而接口返回的 object 数据的属性很多,我们只关注其中部分属性?
{ test1: { @client("echo") req: { obj: { a: 3, b: 4, c: 5, } }, res: { obj: { @partial b: 4, } } } }
接口可能返回一些可选字段,我们使用@optional标记这种字段
{ test1: { @client("echo") req: { v1: 3, // v2: 4, 可选字段 }, res: { v1: 3, v2: 4, @optional } } }
查询字符串
通过 req.query 传入 QueryString
{ test1: { req: { url: "https://httpbin.org/get", query: { k1: "v1", k2: "v2", } }, res: { body: { @partial url: "https://httpbin.org/get?k1=v1&k2=v2", } } } }
当然你可以把 QueryString 直接写在req.url中
{ test1: { req: { url: "https://httpbin.org/get?k1=v1&k2=v2", }, res: { body: { @partial url: "https://httpbin.org/get?k1=v1&k2=v2", } } } }
路径变量
通过 req.params 传入路径变量
{ test1: { req: { url: "https://httpbin.org/anything/{id}", params: { id: 3, } }, res: { body: { @partial url: "https://httpbin.org/anything/3" } } } }
请求头 /响应头
通过 req.headers 传入请求头,通过 res.headers 校验响应头
{ setCookies: { @describe("response with set-cookies header") req: { url: "https://httpbin.org/cookies/set", query: { k1: "v1", k2: "v2", }, }, res: { status: 302, headers: { @partial 'set-cookie': [ "k1=v1; Path=/", "k2=v2; Path=/", ], }, body: "", @type } }, useCookies: { @describe("request with cookie header") req: { url: "https://httpbin.org/cookies", headers: { Cookie: `setCookies.res.headers["set-cookie"]`, @eval } }, res: { body: { @partial cookies: { k1: "v1", k2: "v2", } } }, }, }
校验 http 状态码
{ test1: { req: { url: "https://httpbin.org/status/401", }, res: { status: 401, } } }
用例数据变量导出与引用
凡是执行过的用例其数据均可以当做已自动导出变量,它们均可以被后续用例引用。
Apitest 中可以使用 @eval 注解引用用例数据。
比如上面例子中setCookies.res.headers["set-cookie"],就是引用前面setCookies用例的set-cookie响应头数据。
表单: x-www-form-urlencoded
{ test1: { @describe('test form') req: { url: "https://httpbin.org/post", method: "post", headers: { 'content-type':"application/x-www-form-urlencoded" }, body: { v1: "bar1", v2: "Bar2", } }, res: { status: 200, body: { @partial form: { v1: "bar1", v2: "Bar2", } } } }, }
表单: multipart/form-data
结合 @file 注解实现文件上传
{ test1: { @describe('test multi-part') req: { url: "https://httpbin.org/post", method: "post", headers: { 'content-type': "multipart/form-data", }, body: { v1: "bar1", v2: "httpbin.jsona", @file } }, res: { status: 200, body: { @partial form: { v1: "bar1", v2: "", @type } } } } }
GraphQL
{ test1: { @describe("test graphql") req: { url: "https://api.spacex.land/graphql/", body: { query: ``query { launchesPast(limit: ${othertest.req.body.count}) { mission_name launch_date_local launch_site { site_name_long } } }`` @eval } }, res: { body: { data: { launchesPast: [ @partial { "mission_name": "", @type "launch_date_local": "", @type "launch_site": { "site_name_long": "", @type } } ] } } } } }
http(s)代理
{ @client({ name: "default", type: "http", options: { proxy: "http://localhost:8080", } }) test1: { req: { url: "https://httpbin.org/ip", }, res: { body: { origin: "", @type } } } }
Apitest 支持通过 HTTP_PROXY HTTPS_PROXY 环境变量开全局代理
多个接口服务地址
{ @client({ name: "api1", type: "http", options: { baseURL: "http://localhost:3000/api/v1", } }) @client({ name: "api2", type: "http", options: { baseURL: "http://localhost:3000/api/v2", } }) test1: { @client("api1") req: { url: "/signup", // => http://localhost:3000/api/v1/signup } }, test2: { @client("api2") req: { url: "/signup", // => http://localhost:3000/api/v2/signup } } }
自定义超时
你可以设置客户端超时,影响所有使用该客户端的接口
{ @client({ name: "default", type: "http", options: { timeout: 30000, } }) }
你也可以为某个用例设置超时
{ test1: { @client({options:{timeout: 30000}}) } }
环境变量传递数据
{ test1: { req: { headers: { "x-key": "env.API_KEY", @eval } } } }
mock 数据
{ login1: { req: { url: "/signup", body: { username: 'username(3)', @mock password: 'string(12)', @mock email: `req.username + "@gmail.com"`, @eval } } } }
Apitest 支持近 40 个 mock 函数。下面列些常用的
{ test1: { req: { email: 'email', @mock username: 'username', @mock integer: 'integer(-5, 5)', @mock image: 'image("200x100")', @mock string: 'string("alpha", 5)', @mock date: 'date', @mock // iso8601 格式的当前时间 // 2021-06-03T07:35:55Z date2: 'date("","2 weeks ago")', @mock // 2 周前 sentence: 'sentence', @mock cnsentence: 'cnsentence', @mock // 中文段落 } } }
用例组
{ @describe("这是一个模块") @client({name:"default",kind:"echo"}) group1: { @group @describe("这是一个组") test1: { @describe("最内用例") req: { } }, group2: { @group @describe("这是一个嵌套组") test1: { @describe("嵌套组内的用例") req: { } } } } }
上面的测试文件打印如下
这是一个模块 这是一个组 最内用例 ✔ 这是一个嵌套组 嵌套组内的用例 ✔
跳过用例(组)
{ test1: { @client("echo") req: { }, run: { skip: `othertest.res.status === 200`, @eval } } }
延时执行用例(组)
{ test1: { @client("echo") req: { }, run: { delay: 1000, // 延时毫秒 } } }
重试用例(组)
{ test1: { @client("echo") req: { }, run: { retry: { stop:'$run.count> 2', @eval // 终止重试条件 delay: 1000, // 重试间隔毫秒 } }, } }
重复执行用例(组)
{ test1: { @client("echo") req: { v1:'$run.index', @eval v2:'$run.item', @eval }, run: { loop: { delay: 1000, // 重复执行间隔毫秒 items: [ // 重复执行数据 'a', 'b', 'c', ] } }, } }
如果不在意数据,只想重复执行多少次的话,可以这样设置
{ test1: { run: { delay: 1000, items: `Array(5)`, @eval } } }
强制打印详情
常规模式下,接口如果没有出错是不会打印数据详情的。通过设置run.dump为 true 强制打印详情数据。
{ test1: { @client("echo") req: { }, run: { dump: true, } } }
抽离公用逻辑以复用
首先创建一个文件存储 Mixin 定义的文件
// mixin.jsona { createPost: { // 抽离路由信息到 mixin req: { url: '/posts', method: 'post', }, }, auth1: { // 抽离鉴权到 minxin req: { headers: { authorization: `"Bearer " + test1.res.body.token`, @eval } } } }
@mixin("mixin") // 引入 mixin.jsona 文件 { createPost1: { @describe("写文章 1") @mixin(["createPost", "auth1"]) req: { body: { title: "sentence", @mock } } }, createPost2: { @describe("写文章 2,带描述") @mixin(["createPost", "auth1"]) req: { body: { title: "sentence", @mock description: "paragraph", @mock } } }, }
越是频繁用到的数据越适合抽离到 Mixin 。
自定义函数
某些情况下,Apitest 内置的注解不够用,你可以使用自定义函数。
编写函数lib.js
// 创建随机颜色 exports.makeColor = function () { const letters = "0123456789ABCDEF"; let color = "#"; for (let i = 0; i < 6; i++) { color += letters[Math.floor(Math.random() * 16)]; } return color; } // 判断是否是 ISO8601(2021-06-02:00:00.000Z)风格的时间字符串 exports.isDate = function (date) { return /^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}.d{3}Z$/.test(date) }
使用函数
@jslib("lib") // 引入 js 文件 { test1: { req: { body: { color: 'makeColor()', @eval // 调用 `makeColor` 函数生成随机颜色 } }, res: { body: { createdAt: 'isDate($)', @eval // $ 表示须校验字段,对应响应数据`res.body.createdAt` // 当然你可以直接使用 regex updatedAt: `/^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}.d{3}Z$/.test($)`, @eval } } } }
后记
这里列举了一下 Apitest 使用示例,详细说明请点击github.com/sigoden/apitest查看。