Apifox 自动化测试
0. 为什么要做自动化测试
- 提高稳定性:接口保活、流程可观测
- 解放生产力:自动化、高频、个性化
- 正向可持续:持续迭代、持续测试、持续发布(交付、部署、发布)
你可以跳过下面繁琐的概念和配置部分,直接进入实操教程部分:[五分钟上手教程]
1. 看云文档API导入
- 将 看云文档 API 格式导出 为 OpenAPI 3.0 格式文件
- 导入 至 apifox 中
最新可用脚本在这里:pan.baidu.com/s/1eBzInCsm…
1.1 后续接口文档维护问题
- 接口文档维护还在看云上,看云上更新发布后会自动同步更新到 Apifox 中
- 相同 url 的导入覆盖,只会更新 接口参数等,已自定义的 接口的响应数据格式、接口用例的接口断言脚本和校验响应 等不会受到影响
- 看云只能定义 api 的基础信息:接口名称、接口描述、api_url、参数格式、响应示例(说明)
2. Apifox 环境配置
- 配置 测试、开发、正式环境
- 设置每个环境的 服务(应用)的域名前缀(项目域名)
http://xxx.api.[test/kf].xxx.cn
3. Apifox 公共配置
- 定义 全局变量、环境变量、测试数据变量
- 定义 公共请求头、公共请求参数
- 定义 公共脚本、公共响应格式
业务部分尽量全部使用脚本处理,如使用脚本注入参数和请求头,而不使用公共的参数和请求头,这样便于统一管理。
4. 为接口定义测试用例
4.1 接口用例、测试用例、测定套件、测试数据 的概念定义
- 接口:即 api 接口,主要包含了 api path 、请求方法 等。
- 接口用例:是可重用的接口调试,也是后续接口测试的最小请求单元,它保存了 请求的参数、断言、响应数据格式 等信息,一个接口至少有一个接口用例,可为测试场景定义接口用例,如:
参数正确用例、参数错误用例、数据为空用例、不同数据状态用例等。 - 测试用例:是将多个接口有序地组合在一起运行,用来测试一个完整业务流程。
- 测定套件:测试套件 为 测试用例 的集合,每个测试套件包含多个测试用例。
- 测试数据:
测试用例和测试套件支持测试数据集。当用例或套件运行时,系统会循环运行数据文件里所有的数据集,并且会将数据集里的数据赋值给对应的变量。接口运行时使用变量的地方会读取对应的值(变量优先级:临时变量 > 测试数据变量 > 环境变量 > 全局变量)
(PS:3 测试用例主要用来做接口集合测试,每个测试用例包含多个测试步骤,每个测试步骤都是一个接口请求(2 接口用例) 。测试步骤较多时,建议拆分成多个3 测试用例,然后用4 测试套件将多个测试用例集合在一起运行。)
一个接口(文档)可以有多个接口用例,用于覆盖不同的功能场景,根据测试场景的不同,参数值和断言等可能不同
5. 定义测试套件
集合多个测试用例,定义为一个套件。可对套件进行测试,测试时套件中的每个接口用例会串行执行请求(顺序可拖动接口用例调整)。通常 测试用例 包含完成一个业务测试流程所需要的全部接口用例,它能够体现出一个最小化的完整的业务流程。而 测试套件 则是多个测试流程的合集。
5.1 定义测试套件的测试数据集
为了提高测试的可靠性,可使用多套数据对测试套件进行多轮测试,比如 使用多个园区的数据进行测试,而不是只在一个园区进行测试,这样更能够反映出实际的真实情况,以避免因测试数据的单一而客观上忽略了某些隐藏的错误(幸存者偏差)。
5.2 获取套件的集成测试命令代码
在 Apifox 的 测试用例 和 测试套件 选择 持续集成,生成 实时运行在线数据 的命令:
apifox run http://xxx/api/v1/api-test/ci-config/xxxx/detail?token=xxxx -r html,cli
5.3 套件测试结果的回溯与分析
解析并保留结构化的测试结果,供后续的回溯分析和生成测试报告。
6. 集成持续测试
GitOps = Git + CI + CD
将 Apifox的自动化测试 集成到我们项目的 DevOps 流程当中:
php think apifox
7. Apifox 常用断言/脚本、变量设置/提取 等方法
7.1 常用断言/脚本
Expect / Should - Chai(Apifox 使用 ChaiJS 作为断言库)
(注意: “校验响应” 只会校验数据结构,而不会校验数据值,所以要校验字段值只能使用断言)
脚本的执行顺序:
全局前置脚本 分组前置脚本 接口前置脚本 发送接口请求 返回接口结果 全局后置脚本 分组后置脚本 接口后置脚本
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
请求──────[script1]───────[script2]────────[script3]─────────[send]──────────[res]─────────[script4]───────[script5]───────[script6]────> 结束
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>───>
7.2 变量设置/提取
环境变量 / 全局变量 / 临时变量 | Apifox 使用文档
本地值 和 远程值 的区别:
- 所有使用到变量的地方(
{{variable}}),实际运行的时候都是读写本地值,而不会读写远程值。 - 本地值仅存放在本地,不会同步到云端,团队成员之间也不会相互同步,适合存放
token、账号、密码之类的敏感数据。 - 远程值会同步到云端,主要用来团队成员之间共享数据值。
注意:由于本地值仅存放在本地,使用一些清理软件清理 Apifox 文件缓存会导致本地值被清空,请务必注意。
经测试,
本地值其实是个人值,即 个人账号内可以多端登录同步。
(在线测试时 的本地值呢?cli 用什么本地值?待验证,猜测应该是 生成 cli 命令的那个账号的 个人值)
变量优先级:
所有变量都是通过双大括号的方式引用,当不同类型变量存在相同名称的变量时,系统会根据优先级决定使用哪个类型的变量。
变量优先级:临时变量 > 测试数据变量 > 环境变量 > 全局变量。
7.3 接口之间如何传递数据
测试执行时,接口用例都是串行请求的,所以可以通过后置操作为后续的接口准备临时变量来实现接口间依赖和传递数据的需求。
7.4 请求参数注入
7.5 常用 token、签名、哈希、加密 实现方法
7.6 数据库sql操作
可为其配置一个专门用于数据查询的账号(只有查询的权限)
7.7 socket 接口测试
7.8 如何调用外部代码
脚本调用其他语言( java、python、php 等) | Apifox 使用文档
7.9 如何使用脚本请求调用接口
8. 常见问题
8.1 Apifox 接口管理是在线的吗?
是的,大家需要先注册 Apifox 的账号,并加入团队,和看云是一样的。我们用的是 Saas 版,数据都是存在云端的,需要联网才能使用。所有人都是实时同步,共享同一份在线API文档的。
8.2 Apifox 是免费的吗?
Apifox公网版 (SaaS版) 免费,私有化部署版收费。
8.3 本地值是什么意思?
环境变量/全局变量 里的 本地值 仅存放在本地,不会同步到云端,团队成员之间也不会相互同步,适合存放token、账号、密码之类的敏感数据。 www.apifox.cn/help/app/fa…
注意,对于非 “本地值” ,团队成员之间是共享的,所有人都是实时共享同一份数据。
8.4 如果我想单独测试自己的接口,不想共享被影响怎么办?
建议团队成员始终共享同一份接口文档,这样保证接口文档是最小的,集成测试时也是测试线上最小的接口文档,但如果你需要做一些特殊的测试或者验证时,不想影响团队接口文档,那么你可以尝试下面的方案。
- 可使用快捷接口方便进行快速测试(最好创建自己的目录分组)
- 将接口导出为 Apifox 格式文件,创建自己的个人项目 导入接口进行测试。
8.5 为什么 脚本 无法直接 调用 接口用例 呢?
使用 pm.sendRequest() 请求接口时没有 运行 接口用例 时那些自动的参数,为什么不提供 脚本直接运行 接口用例 的功能呢?
建议使用 组合了多个 接口用例 的 测试用例 来实现需求(示例参考:测试创建各种类型的停车场设备)。
因为这样更能反应真实的业务场景,就像还原我们平常在前端页面上操作的流程一样。
8.6 API 测试 与 单元测试 有什么区别?
传统的单元测试主要针对的是非业务的测试,而API级别的测试,是针对具体业务的功能性测试,旨在检查API契约。
8.7 所有API接口都要做测试用例吗?岂不是很麻烦?
首先要说明的是:
- 测试用例不是银弹和万金油,它无法覆盖所有场景,甚至很多时候其场景和所发挥的测试作用是很有限的
- 测试是有巨大成本的,有调查表明开发人员和QA做测试所花费的时间几乎和写代码的时间一样多
- 自动化测试对开发人员和软件架构要求较高,很多场景需要开发人员在设计和开发阶段就需要考虑到
- 定义接口的功能测试场景很难,粒度太细了很麻烦,粒度粗了覆盖不到,要不断取舍权衡
虽然测试有以上不可回避的缺点,但测试依旧是一件值得做的事:
- 在项目中后期,测试流程对工程质量的保证和开发效率的提升,将会迅速抹平之前所投入的测试建设成本
- 大型商业软件、银行证券业 等公司 测试人员 几乎和开发人员一样多
- 快速开发、快速迭代的大中型团队,内部都会建立 DevOps 开发流程,且已集成好 CI/CD
所以,不能指望测试能解决所有的问题,我们只能尽可能的 catch 到更多care、更重要、风险更高、影响更大、更容易出错 的功能场景,以及尽可能用自动化来完成这些,而这里面所投入的成本和回报是否匹配的问题,只能自己去权衡了。
9. 五分钟上手教程
⏱ 5分钟 建立一个完整场景的测试用例,并进行集成持续测试。
以下教程任选其一,保证都在 5分钟内。
9.1 测试 后台接口:创建园区新闻资讯流程
相关菜单
基础数据/APP管理/资讯列表
基础数据/APP管理/资讯类型
----
相关接口
admin: /auth/v1.pub/login 管理员登录接口
admin: /auth/v1.admin/switchSmartpark 切换园区接口
admin: /smartpark/v1.news/edit 编辑/新增 新闻资讯
admin: /smartpark/v1.news_type/index 新闻资讯列表
admin: /smartpark/v1.news_type/edit 编辑/新增 新闻资讯列表
实现思路:
1. 要完整的测试 整个 创建 新闻资讯的流程,就需要每次都从园区下创建 新的新闻资讯分类,在创建其下的新闻资讯(这里演示 就不创建园区了,以 xiak测试园区1 xiak-test001 为例)
2. 首先 自动登录后台账号,获取 token,此后请求都会带上
3. 然后切换到要创建新闻资讯的目标园区
4. 创建新的 资讯分类,名称必填,且园区内唯一,所以每次名称要带随机串避免出现唯一问题
5. 创建新的 新闻资讯,分类id 参数 使用上一步设置的 临时变量
6. 整个流程走完就说明 测试用例 跑通了,可以集成到 spug ,或随时运行测试
7. 注意,接口用例是串行执行的,要确保执行成功才走下一步,可以使用 throw new Error('msg')
1. 接口用例部分
2. Auth Token 部分
- 接口管理:看云文档导入/admin -> 前置操作 (设置 分组前置操作)
- 添加两个前置操作,选择 公共脚本 [admin pub/login getToken]、[admin Auth token]
- 保存
3. 测试用例 与 测试数据变量 部分
...
敲黑板了,本教程所涉及到的相关知识点:接口文档、接口用例、测试用例、自动化测试、前后置脚本、校验响应格式、断言、变量、测试数据集、接口间依赖、自动处理 Auth 登录、请求参数注入。
9.2 测试 停车场设备端接口:入场流程
....
10. 常用脚本和资源
公共响应: success: 200
{
"data": {},
"msg": "ok",
"code": 1
}
公共响应: error: 200
{
"data": {},
"msg": "err msg",
"code": 0
}
公共响应: success common list: 200
{
"data": {
"total": 1,
"items": [
{
"id": 73
}
]
},
"msg": "ok",
"code": 1
}
断言: result success check
assert result success: code != 0
pm.test("assert result success: code != 0", function () {
var jsonData = pm.response.json();
// console.log(jsonData);
pm.expect(jsonData).to.have.property('code');
pm.expect(jsonData.code).to.not.eql(0);
});
断言: result error check
assert result error: code == 0
pm.test("assert result error: code == 0", function () {
var jsonData = pm.response.json();
// console.log(jsonData);
pm.expect(jsonData).to.have.property('code');
pm.expect(jsonData.code).to.eql(0);
});
断言: success common list total
assert res total: data.total == data.items.list
pm.test("assert res total", function () {
var jsonData = pm.response.json();
// console.log(jsonData);
pm.expect(jsonData.data.total).to.equal(jsonData.data.items.length);
});
请求前注入设备id参数
set params: device_id=sxaxax1658635137
var uuid = require("uuid"), btoa = require('btoa');
var queryParams = pm.request.url.query;
// var base_url = pm.environment.get('BASE_URL');
// var k = 'device_id:' + btoa(base_url);
var k = 'device_id';
// 获取环境变量
var device_id = pm.environment.get(k) || '';
if (device_id === '') {
device_id = uuid.v4().split('-').pop() + String(Date.parse(new Date()) / 1000);
// 设置环境变量
pm.environment.set(k, device_id);
}
// console.log(k, device_id);
// 注入请求参数
queryParams.upsert({
key: k,
value: device_id,
});
请求前注入时间戳ts参数
set params: ts=1658635137
var queryParams = pm.request.url.query;
var k = 'ts';
var ts = String(Date.parse(new Date()) / 1000);
// console.log(k, ts);
// 注入请求参数
queryParams.upsert({
key: k,
value: ts,
});
请求前注入请求id参数
set params: request_id=1++
var queryParams = pm.request.url.query;
var k = 'request_id';
// 获取环境变量
var request_id = pm.environment.get(k) || 0;
if (request_id < 2147483647) {
request_id++;
} else {
request_id = 0;
}
// 设置环境变量
pm.environment.set(k, request_id);
// console.log(k, request_id);
// 注入请求参数
queryParams.upsert({
key: k,
value: request_id,
});
请求前注入随机串参数
set params: unique_str=a85cfxd6yw42rexsm7
function randomString(e) {
e = e || 32;
var t = "abcdefhijkmnprstwxyz2345678",
a = t.length,
n = "";
for (i = 0; i < e; i++) n += t.charAt(Math.floor(Math.random() * a));
return n;
}
var queryParams = pm.request.url.query;
var k = 'unique_str';
var str = randomString(18);
// console.log(k, str);
// 注入请求参数
queryParams.upsert({
key: k,
value: str,
});
admin pub/login getToken
园区后台 获取 token: set environment.sp_admin_token
// 定义发送登录接口请求方法
function sendLoginRequest() {
// 获取环境里的 前置URL
const baseUrl = pm.environment.get("BASE_URL");
const username = pm.environment.get("sp_admin_name");
const password = pm.environment.get("sp_admin_password");
const loginRequest = {
url: baseUrl + "/auth/v1.pub/login",
method: "POST",
header: {
"Content-Type": "application/json",
},
body: {
mode: 'raw',
raw: JSON.stringify({
"name": username,
"pwd": password
}),
}
};
pm.sendRequest(loginRequest, function (err, res) {
if (err) {
console.log(err);
} else {
const jsonData = res.json();
if (jsonData.code == 0) {
// throw new Error('getToken error:' + jsonData.msg);
console.error('getToken error:', jsonData.msg);
return;
}
console.log('Welcome:', jsonData.data.nick_name);
// 将 accessToken 写入环境变量 ACCESS_TOKEN
pm.environment.set("sp_admin_token", jsonData.data.token);
}
});
}
const token = pm.environment.get("sp_admin_token");
if (!token) {
sendLoginRequest();
}
admin: switchSmartpark
园区后台 admin: /auth/v1.admin/switchSmartpark 接口 后置脚本: set environment.sp_admin_token
pm.test("switchSmartpark after javascript", function () {
var jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('code');
pm.expect(jsonData).to.have.property('data');
pm.expect(jsonData.data).to.have.property('token');
console.log('switchSmartpark:', jsonData.data.token);
// 将 accessToken 写入环境变量 ACCESS_TOKEN
pm.environment.set("sp_admin_token", jsonData.data.token);
});
admin Auth token
园区后台 请求前注入 token. set headers: AdminToken=JWT
var headers = pm.request.headers;
var k = 'sp_admin_token';
// 获取环境变量
var token = pm.environment.get(k) || '';
// console.log(k, token);
// 注入请求头
headers.upsert({
key: 'AdminToken',
value: token,
});
请求前注入api版本参数
停车场设备端 请求前注入 version. set params.version: v1.0.0
var queryParams = pm.request.url.query;
var k = 'version';
// 获取环境变量
var v = pm.environment.get(k) || 'v1.0.0';
// console.log(k, v);
// 注入请求参数
queryParams.upsert({
key: k,
value: v,
});
client.car request sign
停车场设备端 请求前注入签名 set params: sign=md5(k1=v1&k2=v2...)
// 获取预先设置为环境变量的 key
let key = pm.environment.get("pt_smartpark_client_key");
// 存放所有需要用来签名的参数
let param = {};
// 加入 query 参数
let queryParams = pm.request.url.query;
queryParams.each(item => {
if (!item.disabled && item.value !== '') { // 启用且非空参数值的参数才参与签名
param[item.key] = item.value;
}
});
// 加入 body 参数
if (pm.request.body) {
let formData;
switch (pm.request.body.mode) {
case 'formdata':
formData = pm.request.body.formdata;
break;
case 'urlencoded':
formData = pm.request.body.urlencoded;
break;
case 'raw':
// 如果没有 JSON 格式的请求 body,或 JSON 格式 body 不参与签名,可以删除这一段
let contentType = pm.request.headers.get('content-type');
if (
contentType
&& pm.request.body.raw
&& contentType.toLowerCase().indexOf('application/json') !== -1
) {
try {
let jsonData = JSON.parse(pm.request.body.raw);
/*
* 注意:通过脚本取出来的接口参数,如果参数包含变量,变量是不会替换成对应的值。如想要获取替换后的值,可使用`pm.variables.replaceIn`方法处理:
* let body = pm.variables.replaceIn(pm.request.body.raw);
* let jsonData = JSON.parse(body);
*/
for (let key in jsonData) {
let value = `${jsonData[key]}`; // 此处要注意如果值的实际类型不是 string 需要根据实际情况处理。
if (value !== '') { // 非空参数值的参数才参与签名
param[key] = value;
}
}
} catch (e) {
console.log('请求 body 不是 JSON 格式')
}
}
break;
default:
break;
}
if (formData) {
formData.each(item => {
if (!item.disabled && item.value !== '') { // 启用且非空参数值的参数才参与签名
param[item.key] = item.value;
}
});
}
}
// console.log(param);
// 取 key
let keys = [];
for (let key in param) {
// 注意这里,要剔除掉 sign 参数本身
if (key !== 'sign' && key !== 'token' && key !== 'tbs') {
keys.push(key);
}
}
// 参数名 ASCII 码从小到大排序(字典序)
keys.sort();
// 转成键值对
let paramPair = [];
for (let i = 0, len = keys.length; i < len; i++) {
let k = keys[i];
paramPair.push(k + '=' + encodeURIComponent(param[k])) // urlencode 编码
}
// 最后加上 key
paramPair.push("key=" + key);
// console.log(paramPair);
// 拼接
let stringSignTemp = paramPair.join('&');
// console.log(stringSignTemp);
let sign = CryptoJS.MD5(stringSignTemp).toString().toUpperCase();
// console.log(sign);
queryParams.upsert({
key: 'sign',
value: sign,
});
client.car getClientToken
停车场设备端 获取 token: set environment.pt_client_token
// 定义发送登录接口请求方法
function sendLoginRequest() {
// 获取环境里的 前置URL
const baseUrl = pm.environment.get("BASE_URL");
const username = pm.environment.get("pt_smartpark_number");
const password = pm.environment.get("pt_smartpark_pwd");
const loginRequest = {
url: baseUrl + "/v1.Pub/getClientToken",
method: "POST",
header: {
"Content-Type": "application/json",
},
body: {
mode: 'raw',
raw: JSON.stringify({
"username": username,
"password": password
}),
}
};
pm.sendRequest(loginRequest, function (err, res) {
if (err) {
console.log(err);
} else {
const jsonData = res.json();
if (jsonData.code == 0) {
// throw new Error('getClientToken error:' + jsonData.msg);
console.error('getClientToken error:', jsonData.msg);
return;
}
console.log('Welcome:', username);
// 将 accessToken 写入环境变量 ACCESS_TOKEN
pm.environment.set("pt_client_token", jsonData.data.token);
}
});
}
const token = pm.environment.get("pt_client_token");
if (!token) {
sendLoginRequest();
}
client.car Auth token
停车场设备端 请求前注入 token. set params: token=JWT
var queryParams = pm.request.url.query;
var k = 'pt_client_token';
// 获取环境变量
var token = pm.environment.get(k) || '';
// console.log(k, token);
// 注入请求参数
queryParams.upsert({
key: 'token',
value: token,
});
11. 相关资料
No.15 和《质量三人行》主播串台谈前端研发与测试如何手拉手做好朋友? - Web Worker-前端程序员们的闲聊 | 小宇宙 - 听播客,上小宇宙(有时间可以听一下,虽然是讲前端的,但相关的经验和知识点对后端来说是一样的)