README
api-test-lib
(本文档持续完善中……,欢迎在 Issue 区留言)
TOC
- 初始化项目
- 推荐的测试项目结构
- 定义第一个接口规格
- 编写测试
- 执行测试
- 定义通过 Post 方法访问的接口
- 期望 DSL 介绍
- 使用工厂函数生成含有路由参数的 Endpoint
- 使用 suite 描述测试场景
- 使用标签 tags 管理测试用例集
- FAQ
初始化项目
建议从我们推荐的 quickstart 开始。
克隆 quickstart 项目为测试项目:
git clone https://github.com/api-test-lib/quickstart.git <your-test-project>
进入测试项目目录:
cd <your-test-project>
安装必要依赖项:
yarn
api-test-lib 项目自带 TS 类型声明文件。
推荐的测试项目结构
./
/spec # 存放接口规格文件
/test # 存放接口测试代码
定义第一个接口规格
// ./spec/greeting.ts
import { Endpoint } from "api-test-lib";
/** 把Url查询参数的规格称为QuerySpec */
export interface QuerySpec {
name: string;
}
/** 把响应体规格称为ReplySpec */
export interface ReplySpec {
message: string;
}
/** 声明一个通过Get方法访问的接口,构造参数为接口路径 */
export default new Endpoint.Get<QuerySpec, ReplySpec>("/greeting");
编写测试
// ./test/greeting.test.ts
import { test } from "api-test-lib";
import greeting from "../spec/greeting";
test("测试打招呼", () =>
greeting.request({
/** 设置请求Url查询参数 */
query: {
name: "TS"
},
/** 响应期望DSL */
expect: {
/** 判断响应码为200 */
statusEquals: 200,
/** 判断响应头包含期望的值 */
headerContains: {
"content-type": "application/json"
},
/** 判断响应体包含期望的值 */
replyContains: {
message: "你好,TS!"
}
}
}));
执行测试
执行测试时,需要指定一个 API_TEST_HOST
环境变量,表示被测试环境的入口地址。
原理,我们会把这个环境变量的值,加上每个 Endpoint 实例化时传入的路径字符串,最终生成被测试接口完整的 Url。
例如,API_TEST_HOST
是 http://127.0.0.1:3000
,某个 Endpoint 的路径是/greeting
,那么其完整的 Url 则是http://127.0.0.1:3000/greeting
。
由于 quickstart 项目已经为您做好了必要的配置,只需执行:
API_TEST_HOST="http://<host>:<port>" yarn test
就可以启动测试了。
定义通过 Post 方法访问的接口
通过 Post 方法访问的接口,一般在请求体中设置入参,但也会遇到同时使用 Url 查询参数设置的情形,此时我们把 Url 查询参数的规格类型,放到泛型声明的第三个参数中:
// ./spec/createUserProfile.ts
import { Endpoint } from "api-test-lib";
export interface QuerySpec {
apikey: string;
}
export interface SendSpec {
name: string;
gender: "male" | "female" | "unknown";
email: string;
mobile?: string;
address?: string;
}
export interface ReplySpec {
error?: string;
result?: { id: number } & SendSpec;
}
/**
* 由于通过Post方法访问的接口,设置Url查询参数的情形相对低频,因此把这个类型信息安排为第三个参数
*/
export default new Endpoint.Post<SendSpec, ReplySpec, QuerySpec>(
"/userprofile/create"
);
使用接口进行测试时:
// ./test/createUserProfile.test.ts
import { test } from "api-test-lib";
import createUserProfile from "../spec/createUserProfile";
test("测试创建用户资料", () =>
createUserProfile.request({
query: {
apikey: "xxxxxx";
},
/** 设置请求体参数,将以application/json编码发送 */
send: {
name: "西蒙",
gender: "male",
email: "simon@company.com"
},
expect: {
statusEquals: 200,
replyContains: {
result: {
/**
* 期望DSL支持将此处期望值设置为一个函数做更灵活的判断
* 函数返回值为一个布尔值,表示检查是否通过
*/
id: id => id > 0,
name: "西蒙"
}
}
}
}));
期望 DSL 介绍
判断响应码是否等于指定值
这个期望检查几乎是最常用的,在 happy path 的测试中,我们一般都判断响应码是否等于 200:
{
expect: {
statusEquals: 200;
}
}
也可以输入其他值,比如 400。
判断响应头是否包含指定值
例想判断响应体是否为 application/json
编码,我们可以这么做:
{
expect: {
statusEquals: 200,
headerContains: {
"content-type": "application/json"
}
}
}
如果对应字段的期望值为一个函数,则可以做更复杂的判断。
例如,我们很可能遇到 Content-Type
的返回值,还附加了内容编码的元数据:application/json; charset=utf-8
。
这种情形,如果像前面那样设置期望值做严格判断检查,必然是失败的,因此我们可以改造期望检查为:
{
expect: {
statusEquals: 200,
headerContains: {
"content-type": ct => ct.startsWith("application/json")
}
}
}
只要 Content-Type
的值以 application/json
开头,那么 startsWith
的计算结果则是 true
,函数返回真值,判断通过。
判断响应体是否包含指定值
对响应体做检查也是非常高频的动作,我们可以像前面使用 headerContains
那样对响应体做检查:
{
expect: {
statusEquals: 200,
replyContains: {
message: "ok"
}
}
}
和 headerContains
不同的是,replyContains
支持复杂对象的深层检查,而不像 headerContains
只支持[请求头]: [请求头的期望值,仅可能为字符串或一个检查函数]
。
正如前面“测试创建用户资料”测试用例中看到的那样:
expect: {
statusEquals: 200,
replyContains: {
/** result是响应体根部的字段 */
result: {
/** 对result内部的值做深层判断 */
id: id => id > 0,
name: "西蒙"
}
}
}
由于期望检查的环境是标准的 Node 环境,因此可以利用一些语言上的特性,方便地执行一些检查。
假设响应体的 result
字段,其值为一个数组,我们判断该数组仅包含一个元素,并且那个元素也是一个 JSON 对象,它的 name
字段值应为 Simon,gender
字段值应为 male,email
字段值应为 simon@gmail.com,可以这么书写期望检查:
expect: {
statusEquals: 200,
replyContains: {
result: {
/** 注意,result是一个数组对象,所以其上存在length属性 */
length: 1,
/** 其中下标为0的元素 */
0: {
/** name字段的值应为Simon */
name: "Simon",
/** gender字段的值应为male */
gender: "male",
/** email字段的值应为simon@gmail.com */
email: "simon@gmail.com"
}
}
}
}
作为对比,我们看下这段检查使用传统的 assert 库来做,代码会是什么样子:
// resp是使用Axios库对接口进行请求取得的响应值
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data.result.length, 1);
assert.strictEqual(resp.data.result[0].name, "Simon");
assert.strictEqual(resp.data.result[0].gender, "male");
assert.strictEqual(resp.data.result[0].email, "simon@gmail.com");
一个眼神确认,就能看出使用期望 DSL 书写检查会更简洁明了。
我们可以尽情利用语言特性带来的便利来简化测试代码。假设期望检查时,字段名称需要根据一个表达式计算得出,那么可以利用 ES6 提供的“Computed property names”特性:
expect: {
statusEquals: 200,
replyContains: {
result: {
/** key变量的值作为字段名称,判断该字段的值应等于value变量 */
[key]: value
}
}
}
判断响应体是否等于指定值
如果想要检查响应体严格等于某个期望值,可以使用replyEquals
:
expect: {
statusEquals: 200,
replyEquals: {
message: "你好,TS!"
}
}
这段期望检查,仅当响应体内容只包含一个message
字段,并且其值为你好,TS!
时,才通过。
由于很多时候,我们的接口会返回一大堆数据,这时我们仅会对“感兴趣”的那部分信息做检查,因此,这个replyEquals
,在大多数测试场景中并不会被使用。
使用工厂函数生成含有路由参数的 Endpoint
很多时候,接口路由需要指定资源 Id 才能最终确定,例如一个节点详情的路由形式可能是:/nodes/<节点Id>/detail
。
这种时候,我们可以在接口 Spec 中导出一个根据入参生成指定 Endpoint 的工厂函数:
// ./spec/getNodeDetail.ts
import { Endpoint } from "api-test-lib";
export default function(id: string) {
return new Endpoint.Get(`/nodes/${id}/detail`);
}
在测试代码中调用该工厂函数,给定节点 Id,得到其对应的 Endpoint:
// ./test/getNodeDetail.test.ts
import { test } from "api-test-lib";
import getNodeDetail from "../spec/getNodeDetail";
test("测试获取节点性情", () =>
/** 对"/nodes/15/detail"进行测试 */
getNodeDetail("15").request({
// ...
}));
使用 suite 描述测试场景
我们看到前面的测试代码示例,都是直接通过 test
函数定义一个测试用例,并且这个测试用例是模块顶级的。
我们推荐您使用 suite
函数,定义一个测试集,来组合测试用例,这个方法尤其适用于场景化测试:
import { suite, test } from "api-test-lib";
suite("作业调度全流程测试", () => {
test("下发一个新作业", () =>
// ...
);
test("等待作业执行结束", () =>
// ...
);
test("检查作业执行成功", () =>
// ...
);
});
下面您马上可以看到,使用 suite
还可以获得通过标签管理测试用例等额外的好处。
使用标签 tags 管理测试用例集
在 suite 中定义标签
在定义一个测试用例集 suite 时,我们可以在其定义函数第二个参数传入一个对象,声明一些元数据,其中就包括非常重要的标签:
suite("XX接口测试", { tags: ["feature", "smoke", "fast"] }, () => {
// ...
});
如上,我们定义了三个标签 feature、smoke、fast,分别表示这个测试用例集:
- 属于功能测试
- 并且是冒烟测试
- 测试过程很快可以结束
如何打标签是门学问,您可以根据自己产品实际的测试场景确定标签候选清单。
通过--tags 参数选择性执行测试
当您的测试用例集已经打上丰富的标签时,可以在启动测试时传入--tags 参数,指明希望执行哪批测试,对不符合给定条件的测试则略过。
如果已经按照前面的方式配置好测试引导过程,当我们以API_TEST_HOST="http://<host>:<port>" yarn test --tags feature
的方式启动测试时,将只会执行包含标签feature
的那些 suite。
当然,我们的标签管理系统不会仅支持这么简单的功能。
通过--tags "feature && smoke"
,将执行既包含feature
又包含smoke
标签的那些 suite。
通过--tags "feature || smoke"
,将执行包含feature
或包含smoke
标签的那些 suite。
通过--tags "!feature && smoke"
,将执行不包含feature
但包含smoke
标签的那些 suite。
相信通过这些示例,您已经掌握了如何编写--tags
参数的诀窍。
FAQ
为什么不支持 Put、Delete 等方法?
目前我们自己产品中实现的接口,仅使用 Get 和 Post 两种请求方式。如果您有支持其他 Http 方法的需求,可以在 Issue 区留言,以便我们扩充。
为什么每个 Endpoint 实例仅支持一种 Http 请求方法?
您可以会质疑为什么不在 Endpoint 实例上提供 Get、Post 等不同方法的调用,而是在实例化 Endpoint 时就必须指定 Endpoint.Get
或 Endpoint.Post
。
这么做的原因是,即便是纯 RESTful 风格的 API 实现,在一个 URI 上,通过不同方法请求背后的语义仍然是不同的,如:
POST /users
,是创建用户,在 Swagger 这类 API 定义语言中,其operationId
可以取值createUser
GET /users
,是获取用户列表,在 Swagger 这类 API 定义语言中,其operationId
可以取值getUserList
我们希望,每一个 Endpoint 实例,其语义就是确定的,这样在测试代码中,就能通过 Endpoint 实例名称,清晰地看到我们正在做什么事。
如何处理非 200 响应,响应体异构的情形?
在一些产品接口实现中,很可能遇到 200 的响应与非 200 的响应,其响应体结构是异构的情形。
对于这种情况,目前的测试框架实现还无法灵活处理,尤其是在泛型绑定时能支持多种 ReplySpec 的特性。
关于这块,我们会结合 TS 的语言特性,继续深入思考设计实现,如果您有什么好的想法,欢迎在 Issue 区留言。
Promise 还是 Async/Await 亦或其他?
众所周知,Node.js 是以单线程的工作模式执行我们编写的代码,当遇到 IO 操作如网络请求、磁盘读写时,函数调用会以异步回调的模式去接收操作完成后返回的值,并进行相关处理。
由于异步回调的形式很容易产生“回调地狱(callback hell)”的问题,因此出现了更好的选择:Promise。
想在 Mocha 框架中使用 Promise 风格编写测试,只需要在测试体函数中接收一个“done”参数,它是一个函数,当你的测试完成时,就去执行它,向测试框架表明当前测试用例已完成:
test("testing with promise", done => {
createJob()
.request({
// 封装请求内容
})
.then(response => {
// request调用返回的是一个Promise
// 对response做处理
done(); // 最后别忘记调用done,表示测试用例已完成
});
});
但这对写习惯同步代码的同学来说,仍然比较“别扭”。此时,可以使用 Async/Await 关键字来调整编码风格。
使用 Async/Await 的要诀很简单:
- 只有在被 async 标注的函数体中,才能使用 await 关键字
- 一个被 async 标注的函数,必然返回一个 Promise 对象
- 可以 await 一个 Promise 对象,返回 Promise 被 resolve 后的值
- 可以 await 一个 Promise 以外的普通 JS 对象,此时返回的就是这个 JS 对象本身
因此,前面的代码可以改造为:
// 测试体函数必须加上async关键字
test("testing with async/await", async () => {
const response = await createJob().request({
// 封装请求内容
}); // 经过await返回得到response
// 对response做处理
});
切记,此时 async 函数不可以再接收并调用 done 参数。
那么,还有没有更好的方法?我们可以观察到如下事实:
- Endpoint.request 返回的是一个 Promise 对象
- 测试框架发现测试体函数返回了一个 Promise 对象后,会一直等待直到 Promise 被完成,才继续后面的执行
- 借助强大的期望 DSL,我们绝大多数对 response 做检查判断的逻辑,都可以在 expect 内部完成
基于这些事实,我们可以继续改造:
test("testing with returning promise", () => {
return createJob().request({
// 封装请求内容
expect: {
// 在这里对response做判断
}
});
});
这样也是可以工作的!
还能更进一步吗?我们借助 ES6 箭头函数不加花括号,直接返回一个表达式的写法,连return
关键字都可以省去:
// 切记!测试体箭头函数不能加花括号!
test("testing with returning promise", () =>
createJob().request({
// 封装请求内容
expect: {
// 在这里对response做判断
}
}));
但是要注意,这里必须省去花括号,表示这个箭头函数直接返回 request 调用后返回的结果,即一个 Promise 对象。
如果加上花括号,又由于缺少return
显式返回一个值,那么此时测试体函数就相当于返回了undefined
,这会产生不可预料的行为。