前言
前些时间,应公司业务需求,想在 vue
项目中,利用 jest 框架做一下接口的集成测试。
但是网上一查呢,利用 jest
做接口测试的案例很少(就连前端测试的 jest
项目案例都很少,这里指实际项目,不是 demo 的那种)。而且经过 jest
官方文档的查阅,发现接口测试这一块需要的一些功能要求,光靠 jest
也很难满足(具体见文章 2.1 功能要求)。
前期一直在 jest
里摸索,想着曲线救国,弯道完成功能实现,但是效果甚微。一直到发现了 allure
( jest-allure
)工具,才给了我一些启示和思路,实现了弯道超车。关于网上 allure
的实际案例么,就更少了,就连官方文档就不是特别友好。经过我个人的翻阅各路资料,加上实际踩坑摸索,才总结了下面一套实际的开发案例,个人感觉还是蛮宝贵的,所以拿出来跟大家分享一下。
第一部分是测试方法和思路设计,跟项目案例无关,只是个人对产品测试的一些总结(这个项目案例应该算灰盒测试了),不感兴趣的同学可以直接跳到第二部分。第二部分是项目案例的功能点拆解,以及每一个功能点的实现思路,方便针对单独的功能点进行查阅。第三部分开始就是项目的完整改造了(因为想尽可能地写完整,所以内容可能会有点长哈^_^)。
1.测试方法和思路设计
1.1 灰盒测试
1.1.1 概念
思想
基于程序运行时的外部表现,同时结合程序内部逻辑结构来设计测试用例
方式
通过执行程序、采集程序路径执行信息、获取外部用户接口结果,来进行测试
目的
旨在验证软件满足外部指标,并且对软件的所有通道或路径都进行了检验
时机
执行时机通常在开发者做完白盒测试之后,在功能测试人员进行大规模集成测试之前
区别
1.1.2 方法和步骤
- 确定程序的所有输入和输出;
- 确定程序所有状态;
- 确定程序主路径(主流程);
- 确定程序的功能(所有分支);
- 产生试验子功能 X 的输入,这里 X 为众多子功能之一;
- 制定验证子功能的 X 的输出;
- 执行测试用例 X 的软件;
- 检验测试用例 X 结果的正确性;
- 对其余子功能,重复(7)和(8);
- 重复(4)~(8),然后再进行(9),进行回归测试。
1.2 测试思路设计
1.2.1 前置操作
1.2.2 场景提炼
1.2.3 断言
1.2.4 后置操作
1.3 一些测试策略
- 互不影响的功能点可以放入同一个用例中进行测试
- 互不影响的分支可以放入同一个用例中进行测试,
A1
中加入B1
,A2
中加入B2
,A3
中不需要再测试B1
、B2
- 基础配置,不参与测试,但需要单独维护一套可用配置,以供流程执行
- 以用户常用的操作流程为主设计用例,不需要所有情况全部覆盖,异常测试也只需要执行用户常见的异常
2.整体思路
名词说明
2.1 功能要求
2.1.1 核心功能
- 接口用例串行测试(非并行)
- 同个测试文件内不同接口用例依次进行测试
- 不同测试文件依次进行测试
- 前面接口用例生成的数据供后面接口用例使用(同个测试文件的同个测试用例中)
- 多次执行某个接口用例
- 前置操作、后置操作
- 断点调试
- 跳过某条接口用例
2.1.2 辅助功能
- 接口信息展示(包括接口信息,出入参)
- 断言展示
- 接口用例的优先级分类展示
- 接口用例的测试结果分类
- 如成功、失败、跳过等结果
- 接口用例本身分组
- 根据接口用例的用户行为或其他依据进行划分
- 环境信息展示
- 历史数据统计信息展示
- 测试覆盖率展示
2.1.3 项目代码要求
2.2 实现思路
2.2.1 接口用例串行测试(非并行)
同个测试文件内不同接口用例依次进行测试
- 在接口用例中使用
return promise;
的形式执行请求
// xxx.test.js
// 会执行完第一个接口用例,并拿到数据后,再执行第二个接口用例
describe('第一个测试用例', ()=>{
it('第一个接口用例',()=>{
return new Promise((resolve)=>{
$http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
resolve();
});
});
});
it('第二个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
});
});
注意:用例顺序不可改变,会影响测试结果
不同测试文件依次进行测试( A 文件执行完才会去执行 B 文件)
- 使用
jest
指令时增加--runInBand
参数(具体npm
指令配置请参考 3.2.1 )
//package.json
{
"script": {
"test": "jest --runInBand"
}
}
2.2.2 前面接口用例生成的数据供后面接口用例使用(同个测试文件的同个测试用例中)
( describe
的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
// xxx.test.js
// 第一个接口用例的出参的值赋给 firstData,并在第二个接口用例中作为入参使用
describe('第一个测试用例', ()=>{
let firstData;
it('第一个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
firstData = res;
});
});
let secondData;
it('第二个接口用例',()=>{
let params = {
data: firstData
};
return $http('/xxx.do', params).then((res)=>{
expect(res).toBeDefined();
secondData = res;
});
});
});
2.2.3 多次执行某个接口用例
使用 jest
的全局方法:test.each(table)(name, fn, timeout)
详见 jest 文档 ( it.each
的完整封装请参考 4.5.1,接口用例的完整调用请参考 4.6.2 )
// xxx.test.js
// 会遍历执行接口用例,但是能使用不同的参数,并且能依次执行
describe('第一个测试用例', ()=>{
// 多次操作的数据
let times = [
[1, 10],
[2, 20],
[3, 30],
]
it.each(times)('第%i次操作,操作数量为%i', (index, count) => {
let params = {
data: count
};
return $http('/xxx.do', params).then((res)=>{
expect(res).toBeDefined();
});
});
});
2.2.4 前置操作、后置操作
使用 jest
的全局方法:beforeAll
/ beforeEach
、afterAll
/ afterEach
详见 jest 文档
注意:
beforeEach
是针对每一个test/it
进行一次前置操作,而不是describe
;- 要针对每个
describe
进行前置操作,要在describe
内部使用beforeAll
;
// xxx.test.js
describe('第一个测试用例', ()=>{
// 预先生成接口用例所需的数据
let beforeData;
beforeAll(()=>{
return $http('/xxx.do').then((res)=>{
beforeData = res;
});
});
it('第一个接口用例',()=>{
let params = {
data: beforeData
};
return $http('/xxx.do', params).then((res)=>{
expect(res).toBeDefined();
});
});
it('第二个接口用例',()=>{
let params = {
data: beforeData
};
return $http('/xxx.do', params).then((res)=>{
expect(res).toBeDefined();
});
});
// 状态归位,资源释放
afterAll(()=>{
return $http('/xxx.do');
});
});
2.2.5 断点调试
可以使用 vscode
的 node.js debug terminal
直接调试
2.2.6 跳过某条接口用例
- 新定义
it
方法,在不满足特定条件时不调用function
(it
的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )- 这样
it
内的函数体就不会执行
- 这样
- 使用
allure
的endCase
方法将用例状态手动改为skipped
(reporter
的完整封装请参考 4.5.1 )- 需要
jest-allure
依赖支持
- 需要
// jest.config.js 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法
import { skipTest } from '@/utils/testUtils/reporter';
const originIt = global.it;
global.it.custom = (
desc,
fn = () => {},
conditions
) => {
const handleFn = () => {
// 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
if (conditions) {
return fn();
} else {
// 设置用例的 skipped 状态
skipTest();
return;
}
};
originIt(itDesc, handleFn);
};
// reporter.js 测试报告文件
// 设置用例的 skipped 状态
export function skipTest(conditions) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
if (conditions) {
// 设置 skipped 状态,添加相应信息
reporter.allure.endCase('skipped', {
message: reporter.allure.getCurrentSuite().name
});
}
}
}
// xxx.test.js
// 调用新定义的 it 方法来跳过用例
describe('第一个测试用例', ()=>{
// 跳过第一个接口用例
it.custom('第一个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, false);
// 第二个接口用例不跳过
it.custom('第二个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, true);
});
2.2.7 接口信息展示
- 使用
allure
reporter
的description
方法输出接口信息( 请求方法的完整封装请参考 4.5.2 )- 需要
jest-allure
依赖支持
- 需要
- 使用
allure
reporter
的addParameter
方法输出参数信息(reporter
的完整封装请参考 4.5.1 )- 需要
jest-allure
依赖支持
- 需要
// httpService.js 测试层使用的接口请求方法
import { requestReporter, paramsReporter } from '@/utils/testUtils/reporter';
function service(url, params) {
return new Promise((resolve, reject) => {
request(
{
url: url,
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
},
function (error, res, body) {
let data = body;
// 尝试 json 格式转换
try {
data = JSON.parse(body);
} catch (err) {}
// 输出请求日志
const requestInfo = JSON.stringify({ url, params, data });
requestReporter(requestInfo);
// 输出请求参数
paramsReporter(params);
resolve(data);
}
);
});
}
// reporter.js 测试报告文件
/**
* 请求报告
* @param {object} requestInfo 请求信息
*/
export function requestReporter(requestInfo) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.description(requestInfo);
}
}
/**
* 参数报告
* @param {object|array} params 参数
*/
export function paramsReporter(params) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
// 如果参数是对象,遍历每个 key 值输出信息;如果是数组,直接输出数组信息
if (Object.prototype.toString.call(params) === '[object Object]') {
const labels = Object.keys(params);
labels.forEach((item) => {
reporter.addParameter('argument', item, JSON.stringify(params[item]));
});
} else if (Object.prototype.toString.call(params) === '[object Array]') {
reporter.addParameter('argument', 'params', JSON.stringify(params));
}
}
}
2.2.8 断言展示
- 重新封装断言的调用方式,每个断言都传入断言信息
- 使用
allure
reporter
的startStep
、endStep
方法输出断言信息- 需要
jest-allure
依赖支持
- 需要
// xxx.test.js
// expectReporter 为新封装的断言方法,使用时传入断言信息
import { expectReporter } from '@/utils/testUtils/reporter';
describe('第一个测试用例', ()=>{
it('第一个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expectReporter('xxx接口返回数据不能为空', () => {
expect(res).toBeDefined();
});
});
});
});
// reporter.js 测试报告文件
/**
* 断言报告
* @param {string} customMsg 断言信息
* @param {function} declare 断言
*/
export function expectReporter(customMsg, declare) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.startStep(customMsg);
declare();
reporter.endStep(Status.Passed);
} else {
declare();
}
}
2.2.9 接口用例的优先级分类展示
- 在新定义的
it
方法中增加优先级分类展示的逻辑(it
的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 ) - 使用
allure
reporter
的severity
方法设定接口用例的优先级- 由不重要到重要依次为
trivial
、minor
、normal
、critical
、blocker
- 由不重要到重要依次为
// jest.config.js 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法,加入优先级分类展示的逻辑
import { skipTest, severityReporter } from '@/utils/testUtils/reporter';
const originIt = global.it;
global.it.custom = (
desc,
fn = () => {},
conditions,
severity = 3
) => {
const handleFn = () => {
// 根据 severity 参数传入的值来划分优先级
if (Number.isInteger(severity) && severity <= 5 && severity >= 1) {
severityReporter(Number(severity));
}
// 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
if (conditions) {
return fn();
} else {
// 设置用例的 skipped 状态
skipTest();
return;
}
};
originIt(itDesc, handleFn);
};
// reporter.js 测试报告文件
import { Severity } from 'jest-allure/dist/Reporter';
// 优先级枚举,越大越重要
const levelMap = {
1: Severity.Trivial,
2: Severity.Minor,
3: Severity.Normal,
4: Severity.Critical,
5: Severity.Blocker
};
/**
* 设定用例优先级(重要程度)
* @param {number} level 优先级,1 微不足道,5 非常重要
*/
export function severityReporter(level) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.severity(levelMap[level]);
}
}
// xxx.test.js
// 调用新定义的 it 方法来设置接口用例优先级
describe('第一个测试用例', ()=>{
// 设置优先级为最不重要
it.custom('第一个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, true, 1);
// 设置优先级为最重要
it.custom('第二个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, true, 5);
});
2.2.10 接口用例的测试结果分类
- 用例结果的五种状态
passed
:接口用例成功failed
:接口用例失败broken
:接口请求失败skipped
:跳过接口用例unknown
:其他异常情况
- 使用
allure
的endCase
方法手动修改用例的结果状态- 在自定义
it
方法、自定义it.skip
方法、http
服务封装中调用,分别设置skipped
和broken
的结果状态(it
的完整封装请参考 4.5.1,http
服务的完整封装请参考 4.5.2,reporter
的完整封装请参考 4.5.1 ) - 其他
failed
状态通过自定义结果分类来过滤和区分 - 需要
jest-allure
依赖支持
- 在自定义
- 自定义的结果分类
- 跳过测试:状态【
skipped
】- 包括根据条件手动跳过,以及使用
it.skip
自动跳过
- 包括根据条件手动跳过,以及使用
- 请求失败:状态【
broken
】- 包括请求报错、返回非
200
状态码等(不包括请求超时)
- 包括请求报错、返回非
- 断言失败:状态【
failed
】,错误匹配【含declare
】 - 请求超时:状态【
failed
】,错误匹配【含Timeout
】- 确切的表达应该是断言超时,但断言一般消耗时间很短,更多是由请求超时导致的断言超时
- 代码报错:状态【
failed
】,错误匹配【不含Timeout
和declare
】 - 未知错误:状态【
unknown
】- 其他未识别的异常状态,包括使用
it.only
的跳过文件
- 其他未识别的异常状态,包括使用
- 跳过测试:状态【
- 配置自定义结果分类文件
categories.json
- 需要
jest-allure
依赖支持 - 生成
allure-report
前需要将categories.json
文件复制到allure-result
目录下(复制文件的脚本请参考 3.2.1 )
- 需要
// categories.json 文件(固定命名),自定义结果分类
[
{
"name": "跳过测试",
"matchedStatuses": ["skipped"]
},
{
"name": "未知错误",
"matchedStatuses": ["unknown"]
},
{
"name": "代码报错",
"traceRegex": "^(((?!Timeout)(?!declare).)*)$",
"matchedStatuses": ["failed"]
},
{
"name": "断言失败",
"traceRegex": ".*declare.*",
"matchedStatuses": ["failed"]
},
{
"name": "请求失败",
"matchedStatuses": ["broken"]
},
{
"name": "请求超时",
"traceRegex": ".*Timeout.*",
"matchedStatuses": ["failed"]
}
]
// reporter.js 测试报告文件
import { Status } from 'jest-allure/dist/Reporter';
// 状态枚举
const statusMap = {
// 通过测试
passed: Status.Passed,
// 测试中
pending: Status.Pending,
// 手动跳过测试,或使用 skip 跳过测试
skipped: Status.Skipped,
// 未通过测试
failed: Status.Failed,
// 接口请求失败
broken: Status.Broken,
// 使用 only 跳过测试
unknown: 'unknown'
};
// 设置用例的 skipped 状态
export function skipTest(conditions) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
if (conditions) {
// 设置 skipped 状态,添加相应信息
reporter.allure.endCase('skipped', {
message: reporter.allure.getCurrentSuite().name
});
}
}
}
/**
* 当前用例不在测试范围,自动设置 skip
*/
export function autoSkip() {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.allure.endCase(statusMap['skip'], {
message: '不在当前测试范围的用例'
});
}
}
/**
* 将测试结果设置为 broken
*/
export function brokenTest() {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.allure.endCase(statusMap['broken'], {
message: reporter.allure.getCurrentSuite().name
});
}
}
// jest 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法
import { autoSkip } from '@/utils/testUtils/reporter';
const originIt = global.it;
// 自定义 it 方法在跳过接口用例的实现思路中已存在,此处不再重复叙述
global.it.custom = ()=>{};
/**
* 重写自动跳过逻辑
* @param {string} desc 描述信息
*/
global.it.skip = (desc) => {
global.it.custom(desc, () => {
autoSkip();
return;
});
};
// httpService.js 测试层使用的接口请求方法
import { brokenTest } from '@/utils/testUtils/reporter';
function service(url, params) {
return new Promise((resolve, reject) => {
request(
{
url: url,
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
},
function (error, res, body) {
let data = body;
// 尝试 json 格式转换
try {
data = JSON.parse(body);
} catch (err) {}
if (error || res.statusCode !== 200) {
// 请求失败时修改用例状态为 broken
brokenTest();
reject(data);
} else {
resolve(data);
}
}
);
});
}
2.2.11 接口用例本身分组
- 使用
allure
reporter
的epic
、feature
、story
三个方法手动对用例进行场景分类(it
的完整封装请参考 4.5.1,接口用例的完整调用请参考 4.6.2 )epic
、feature
、story
为三层级联关系的场景- 同一级的相同场景的用例会放在一个组中
- 上级场景可以包含多个下级场景,用例可以放置在任意一级的场景下分组下
- 需要
jest-allure
依赖支持
// reporter.js 测试报告文件
/**
* 设定用例的用户场景分组
* @param {array} scenes 三级场景描述
*/
export function behaviorsReporter(scenes = []) {
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
if (scenes[0] && scenes[1] && scenes[2]) {
reporter.epic(scenes[0]).feature(scenes[1]).story(scenes[2]);
} else if (scenes[0] && scenes[1]) {
reporter.epic(scenes[0]).feature(scenes[1]);
} else if (scenes[0]) {
reporter.epic(scenes[0]);
}
}
}
// jest 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法,加入优先级分类展示的逻辑
import { skipTest, severityReporter, behaviorsReporter } from '@/utils/testUtils/reporter';
const originIt = global.it;
global.it.custom = (
desc,
fn = () => {},
conditions,
severity = 3,
scenes = []
) => {
const handleFn = () => {
// 根据 severity 参数传入的值来划分优先级
if (Number.isInteger(severity) && severity <= 5 && severity >= 1) {
severityReporter(Number(severity));
}
// 根据 scenes 来划定用例场景分组
if (Array.isArray(scenes)) {
behaviorsReporter(scenes);
}
// 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
if (conditions) {
return fn();
} else {
// 设置用例的 skipped 状态
skipTest();
return;
}
};
originIt(itDesc, handleFn);
};
// xxx.test.js
// 调用新定义的 it 方法来设置接口用例场景分组
describe('第一个测试用例', ()=>{
// 设置一级场景
it.custom('第一个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, true, 1, ['第一个测试用例']);
// 设置两级场景
it.custom('第二个接口用例',()=>{
return $http('/xxx.do').then((res)=>{
expect(res).toBeDefined();
});
}, true, 5, ['第一个测试用例', '第二个接口用例']);
});
2.2.12 环境信息展示
- 配置自定义环境信息
environment.xml
- 需要
jest-allure
依赖支持 - 生成
allure-report
前需要将environment.xml
文件复制到allure-result
目录下(复制文件的脚本请参考 3.2.1 )
- 需要
- 也可以在接口用例中添加环境信息,需要使用
allure
reporter
的addEnvironment
方法添加
// environment.xml 配置环境信息
<environment>
<parameter>
<key>账号</key>
<value>xxxxxx</value>
</parameter>
<parameter>
<key>服务</key>
<value>http://xxxxx</value>
</parameter>
</environment>
// reporter.js 测试报告文件
/**
* 添加环境信息(展示用)
* @param {string} label 信息名
* @param {string} value 信息值
*/
export function addEnvironment(label, value) {
// 确保在 it 中执行,否则会报错
if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
reporter.addEnvironment(label, value);
}
}
2.2.13 历史数据统计信息展示
- 需要在执行
jest
测试前,将allure-report
目录下的history
文件夹复制到allure-results
目录下,作为历史数据统计的数据源(复制文件的脚本请参考 3.2.1 )
2.2.14 测试覆盖率展示
- 使用
jest
指令时增加--coverage
参数(具体npm
指令配置请参考 3.2.1 )
//package.json
{
"script": {
"test": "jest --coverage"
}
}
3.环境搭建
3.1 依赖安装
3.1.1 测试相关依赖
@vue/test-utils
:vue 的测试工具@vue/cli-plugin-unit-jest
:vue 集成的 jest 工具,集成了以下内容vue-jest
:解析 vue 语法jest-transform-stub
:解析静态资源babel-jest
:解析 js/jsx 语法jest-serializer-vue
:用于快照测试jest-watch-typeahead/filename
:监听 文件名 变化jest-watch-typeahead/testname
:监听 文件名 变化
@babel/plugin-proposal-object-rest-spread
:node 环境下支持对象拓展运算符jest-expect-message
:断言失败时的自定义信息jest-allure
:生成可视化测试报告allure-commandline
:支持 allure 指令
3.1.2 业务相关依赖
3.2 配置文件
3.2.1 package.json
环境配置
脚本执行思路
allure-result:setup
指令- 清空
allure-result
测试结果文件夹(如果存在的话) - 复制
allure-report
测试报告文件夹中的history
历史数据文件到allure-result
文件夹中(如果存在的话)- 用于生成测试报告的历史数据图表
- 清空
jest
指令- 运行
jest
脚本进行测试 --coverage
参数会同时生成测试覆盖率报告
- 运行
allure:setup
指令- 生成测试报告的环境信息、用例分类信息文件
- 将两个文件复制到
allure-result
测试结果目录下,供测试报告生成时使用
allure generate
指令- 基于
allure-result
测试结果生成allure-report
测试报告文件testReporters/allure-results
:源数据文件--clean
:删除已有的allure-report
文件--output testReporters/allure-report
:输出目录
- 基于
allure serve
指令
综上所述 -- 主要使用以下几个脚本
- 执行
npm run test
:进行测试并生成allure-result
测试结果文件 - 执行
npm run test:allure
:基于allure-result
测试结果文件生成allure-report
测试报告文件,并开启一个测试报告的web
服务 - 执行
npm run test:coverage
:进行测试并生成coverage
测试覆盖率文件
{
"scripts": {
"test": "npm run allure-result:setup && cross-env TEST_ENV=jest jest --runInBand",
"test:allure": "npm run allure:setup && npm run allure:generate && npm run allure:serve",
"test:coverage": "npm run allure-result:setup && cross-env TEST_ENV=jest jest --runInBand --coverage",
"allure-result:setup": "node src/config/testConfig/allureResultSetup.js",
"allure:setup": "node src/config/testConfig/allureSetup.js",
"allure:generate": "allure generate testReporters/allure-results --clean --output testReporters/allure-report",
"allure:serve": "allure serve testReporters/allure-results",
}
}
allureResultSetup.js
注意:allure-result
会默认生成到根目录,若要自定义生成目录,请在 jest.config.js
配置的 setupFilesAfterEnv
属性所定义的文件中添加 global.reporter.allure.options.targetDir = 'testReporters/allure-results';
( setupFilesAfterEnv
文件请参考 4.5.1 )
const fs = require('fs');
const path = require('path');
// 复制 allure-report 的 history 到 allure-results 目录下,用于展示历史数据
let fromPath = path.join(
__dirname,
'../../../testReporters/allure-report/history'
);
let toPath = path.join(
__dirname,
'../../../testReporters/allure-results/history'
);
let allureResultPath = path.join(
__dirname,
'../../../testReporters/allure-results'
);
deleteFolder(allureResultPath);
copyFolder(fromPath, toPath);
/**
* 删除文件夹下的文件
* @param {string} path 要删除的路径
*/
function deleteFolder(path) {
if (fs.existsSync(path)) {
let files = fs.readdirSync(path);
files.forEach((file) => {
var curPath = path + '/' + file;
if (fs.statSync(curPath).isDirectory()) {
deleteFolder(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
}
/**
* 复制文件夹到目标位置
* @param {string} from 被复制的文件路径
* @param {string} to 目标文件路径
*/
function copyFolder(from, to) {
if (!fs.existsSync(from)) {
return;
}
// 文件是否存在 如果不存在则创建
if (fs.existsSync(to)) {
let files = fs.readdirSync(from);
files.forEach((file) => {
let fromPath = from + '/' + file;
let toPath = to + '/' + file;
if (fs.statSync(fromPath).isDirectory()) {
copyFolder(fromPath, toPath);
} else {
// 拷贝文件
fs.copyFileSync(fromPath, toPath);
}
});
} else {
fs.mkdirSync(to, { recursive: true });
copyFolder(from, to);
}
}
allureSetup.js
const fs = require('fs');
const path = require('path');
copyFiles();
/**
* 复制文件
*/
function copyFiles() {
// categories.json:用于创建自定义缺陷分类;environment.xml:用于展示环境信息
let files = ['categories.json', 'environment.xml'];
files.forEach((item) => {
// 复制文件到 allure-results 目录下
let sourceFile = path.join(__dirname, item);
let destPath = path.join(
__dirname,
'../../../testReporters/allure-results/' + item
);
let readStream = fs.createReadStream(sourceFile);
let writeStream = fs.createWriteStream(destPath);
readStream.pipe(writeStream);
});
}
3.2.2 jest.config.js
module.exports = {
// 改成 node 将获取不到 dom 元素,引用的依赖会报错
// testEnvironment: 'jsdom',
// n 次失败后停止,0 为即使失败也继续执行
bail: 0,
// 报告每个单独的测试
verbose: true,
// 自动添加文件后缀
moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
// 文件解析
transform: {
'^.+\\.vue$': 'vue-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$':
'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest'
},
// 不需要解析的文件夹
transformIgnorePatterns: ['/node_modules/'],
// 模块名代理配置
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// 快照序列化
snapshotSerializers: ['jest-serializer-vue'],
// 匹配的测试文件
testMatch: ['**/__tests__/**/intoLocation.test.(js|jsx|ts|tsx)'],
// jsdom 环境的 url,脚本中的 location 等信息从此处获取
testURL: 'http://localhost/',
// 监听工具
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
],
// 关于 jest 的全局统一配置
setupFilesAfterEnv: [
'jest-expect-message',
'jest-allure/dist/setup',
'<rootDir>/src/config/testConfig/jestSetup.js'
],
// 测试覆盖率
collectCoverage: false,
coverageReporters: ['json', 'lcov', 'text', 'clover'],
coverageDirectory: 'testReporters/coverage',
collectCoverageFrom: ['src/api/service/**.js'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: -10
}
}
// 全局变量
// globals: {}
};
3.2.3 babel.config.js
module.exports = {
env: {
// 添加测试环境的配置
test: {
plugins: [
[
'@babel/plugin-proposal-object-rest-spread',
{
loose: true, // 使用 Babel's extends helper 的对象拓展运算符
useBuiltIns: true // 直接将 拓展运算符 转换成 Object.assign
}
]
]
}
}
};
3.2.4 eslintrc.js
module.exports = {
env: {
browser: true,
es6: true,
node: true,
amd: true,
// 支持 jest 语法
jest: true
},
globals: {
// 支持 reporter 全局变量
reporter: false
}
};
4.项目改造
4.1 改造思路
- API 层,请求实体方法区分(具体请参考 4.3.1 )
- 分为业务层、测试层的请求方法
- 数据层从业务层抽离
- 数据配置项、数据校验项独立存放(具体请参考 4.4.2 )
- 业务层、测试层共同使用
- 一些测试的公用方法封装
- 用例方法重写,如
it
、describe
、it.each
、it.skip
等(具体请参考 4.5.1 )- 为了加入额外的功能,如实现用例跳过、设置优先级、设置分组等
- 测试层的请求方法封装(具体请参考 4.5.2 )
- 用例方法重写,如
- 测试文件的执行
4.2 目录结构
--src
--api
--__test__(接口测试目录)
--process(进程目录,将各模块每一步进程的操作打散,待测试文件调用)
xxx.test.js(测试文件,基于进程代码的排列组合,形成测试流程)
...
--httpFactory(项目封装的 http 方法)
--service(项目的接口管理,包含所有接口的出入参管理)
--config
--testConfig
paramsConfig.js(接口配置项,包括登录接口、参数等)
jestSetup.js(关于 jest 的统一配置)
allureSetup.js(关于 allure 的统一配置)
allureResultSetup.js(生成 allure-result 的配置项)
categories.json(allure 的测试报告类别配置)
environment.xml(allure 的测试报告环境信息)
--utils
--testUtils(测试的公共方法目录)
declare.js(测试断言管理)
httpService.js(测试的 http 请求封装)
login.js(测试的登录逻辑)
methods.js(测试的公共方法)
reporter.js(测试报告方法)
--views
--warehouse(某一模块)
--intoLocationModule(当前页面下的组件)
intoLocationFields.js(抽离的当前页面的数据层)
intoLocationDialog.vue(页面里的某个弹窗)
intoLocation.vue(当前页面)
...
4.3 API 层
4.3.1 请求实体封装
- 需要区分业务层的
http
方法和测试层的http
方法(具体封装逻辑请参考 4.5.2 )- 测试层的
http
方法需要自动登录,业务层不需要 - 业务层使用
axios
封装请求,而测试层则需要使用node
的request
模块封装请求- 使用
axios
在jsdom
环境(jest
的测试环境)中调用接口会报跨域问题 - 若改成
node
环境,项目中使用到document
的文件(依赖)会报错
- 使用
- 测试层的
http
方法需要输出接口信息
- 测试层的
// baseService.js
import httpFactory from '../httpFactory'; //项目中的 http 方法封装
import httpService from '@/utils/testUtils/httpService'; //测试用的 http 方法封装(包含自动登录逻辑)
export function baseApi(service, value = {}, method = 'post') {
// 通过环境变量来区分是否处于测试阶段
return process.env.TEST_ENV
? httpService(service, value, method)
: httpFactory(service, value, method);
}
4.3.2 API 封装
- 所有
API
均调用同一个请求实体
// addressService.js
import { baseService } from '../base-service';
const addressService = {
getAddress(params) {
// 入参统一处理
params.count = 1;
return baseService('/getAddress.do', params).then((res)=>{
// 出参统一处理
res.code = 'xxxx';
return res;
});
}
};
export default addressService;
4.3.3 业务层 & 测试层调用
- 业务层、测试层均调用同一个
API
- 保证出入参统一处理
- 后期迭代更新只用修改
API
层一处
import addressService from '@/api/addressService';
...
let params = {
area: 11
};
addressService(params).then((res)=>{
let data = res;
...
});
...
4.4 数据层
4.4.1 数据层从业务中抽离
4.4.2 数据层包括
- 展示类数据
- 如 table 数据、信息展示数据等
- 若存在与业务交互的场景,则交互模块可由业务层作为参数传入
- 定义
testFields
等字段,作为测试字段使用
- 校验类数据
- 如表单数据等
- 若存在与业务交互的场景,则交互模块可由业务层作为参数传入
- 自定义一套配置规则,供业务层、测试层共同使用
- 业务层解析为校验规则、长度限制、默认值、枚举项等信息
- 测试层解析并生成符合要求的随机数据
- 特殊情况可以传入自定义异常的参数(校验特定场景/校验异常情况等)
/**
* 订单详情字段
* @param {*} detailData 订单详情数据
*/
export function detailFields(detailData = {}) {
return [
{
label: '订单编号',
value: detailData.orderId,
testFields: ['orderId']
},
{
label: '产品名称',
value: detailData.productName,
testFields: ['productName']
},
{
label: '订单数量',
value: detailData.orderAmount,
testFields: ['orderAmount']
},
{
label: '联系方式',
value: detailData.contactWay,
testFields: ['contactWay']
},
{
label: '收货人',
value: detailData.consignee,
testFields: ['consignee']
},
{
label: '收货地址',
value: detailData.addressDetail,
testFields: ['addressDetail', 'inventoryAmount']
}
];
}
/**
* 入库表单校验字段,输入限制的规则是自定义的
*/
export const intoLocationValidate = {
amountSave: (detailData = {}) => {
return {
type: 'input', // 输入框
required: '请输入入库数量', // 必填
regexp: 'posInt', // 正则规则
maxLimit: detailData.amountNotInto, // 最大限制
minLimit: null, // 最小限制
maxLength: null // 输入最大长度
};
},
stockType: () => {
return {
type: 'select', // 下拉框
options: enumMap.intoLocationType, // 下拉选项枚举
required: '请选择入库类型' // 必填
};
}
};
4.5 测试公共方法
4.5.1 用例方法重写
( jest.config.js 文件配置请参考 3.2.2 )
// jest.config.js 配置的 setupFilesAfterEnv 属性所定义的文件,在每个用例文件之前执行
import {
skipTest,
autoSkip,
severityReporter,
behaviorsReporter
} from '@/utils/testUtils/reporter';
// 设置测试结果输出目录
global.reporter.allure.options.targetDir = 'testReporters/allure-results';
// 设置超时时间
jest.setTimeout(30000);
// 创建 describe、it 的自定义方法,用于自动生成用例编号、跳过测试
let describeIndex = 0;
let itIndex = 0;
let describeData = {};
const originDescribe = global.describe;
const originIt = global.it;
/**
* 自定义 describe 方法
* @param {string} desc 描述信息,将自动生成用例编号
* @param {function} fn 回调函数,传回当前 describe 的使用数据
*/
global.describe.custom = (desc, fn) => {
describeIndex++;
itIndex = 0;
// 重命名 describe 信息
const newDesc =
getTestFileName() + '-desc' + getTwoDigitNum(describeIndex) + '-' + desc;
// 将数据对象作为参数传入回调函数
const handleFn = () => {
let describeName = newDesc;
describeData[describeName] = {};
fn(describeData[describeName]);
};
originDescribe(newDesc, handleFn);
};
/**
* 自定义 describe.each 方法
* @param {array} data 遍历的数据
* @return {function} fn 返回单个 describe 方法,并带以下参数
* item:遍历的数据,index,以及其他 describe 的回调参数
*/
global.describe.eachCustom = (data) => {
return (desc, fn) => {
data.forEach((item, index) => {
const handleFn = (...arg) => {
return fn(item, index, ...arg);
};
// desc 中可以定义 %d:index+1,%s:item,%o[label]:item[label]
let eachDesc = desc
.replace('%d', index + 1)
.replace('%s', item)
.replace(/%o\[([a-zA-Z]+)\]/g, (str, $1) => {
return item[$1];
});
global.describe.custom(eachDesc, handleFn);
});
};
};
/**
* 自定义 it 方法
* @param {string} desc 描述信息,将自动生成用例编号
* @param {function} fn 回调函数,将执行 skip 逻辑
* @param {array} conditionFields 跳过条件
* @param {number} severity 严重程度,1-5 依次增加
* @param {array} scenes 场景分类,可以传后两级(第一级固定为页面名)
*/
global.it.custom = (
desc,
fn = () => {},
conditionFields = [],
severity = 3,
scenes = []
) => {
itIndex++;
// 重命名 it 信息
const describeDesc =
describeIndex > 0 ? '-desc' + getTwoDigitNum(describeIndex) : '';
const itDesc =
getTestFileName() +
describeDesc +
'-it' +
getTwoDigitNum(itIndex) +
'-' +
desc;
/**
* 设置用例优先级、用户行为场景
*/
const setSeverityAndBehaviors = () => {
if (Number.isInteger(severity) && severity <= 5 && severity >= 1) {
severityReporter(Number(severity));
}
if (Array.isArray(scenes)) {
behaviorsReporter(scenes);
}
};
// 执行回调前进行必须项校验,不通过则直接 skip
const handleFn = () => {
setSeverityAndBehaviors();
if (!conditionFields) {
return fn();
} else {
let describeName = reporter.allure.getCurrentSuite().name;
let conditions = {};
conditionFields.forEach((item) => {
conditions[item] = describeData[describeName][item];
});
if (skipTest(conditions)) {
return;
} else {
return fn();
}
}
};
originIt(itDesc, handleFn);
};
/**
* 自定义 it.each 方法
* @param {array} data 遍历的数据
* @return {function} fn 返回单个 it 方法,并带以下参数
* item:遍历的数据,index
*/
global.it.eachCustom = (data) => {
return (desc, fn, ...arg) => {
data.forEach((item, index) => {
const handleFn = () => {
return fn(item, index);
};
// desc 中可以定义 %d:index+1,%s:item,%o[label]:item[label]
let eachDesc = desc
.replace('%d', index + 1)
.replace('%s', item)
.replace(/%o\[([a-zA-Z]+)\]/g, (str, $1) => {
return item[$1];
});
global.it.custom(eachDesc, handleFn, ...arg);
});
};
};
/**
* 重写自动跳过逻辑
* @param {string} desc 描述信息,将自动生成用例编号
*/
global.it.skip = (desc) => {
global.it.custom(desc, () => {
autoSkip();
return;
});
};
/**
* 获取 test 文件名
*/
function getTestFileName() {
// global.jasmine.testPath = "D:\WebstormProjects\WMS\djwms_web-transfer_hzkFork\src\api\__tests__\intoLocation.test.js"
const reg = /([A-Za-z]+)\.test\.js/g;
const testFileName = reg.exec(global.jasmine.testPath)[1];
return testFileName;
}
/**
* 数字转换为两位字符串
* @param {number} num 数字
*/
function getTwoDigitNum(num) {
const str = String(num);
return str.length === 1 ? '0' + str : str;
}
// reporter.js
import { Severity, Status } from 'jest-allure/dist/Reporter';
// 优先级枚举,越大越重要
const levelMap = {
// 微不足道(失败不会阻塞流程,但能反映较小的问题)
1: Severity.Trivial,
// 不重要(失败不会阻塞流程,但能反映一定问题,如普通信息展示错误)
2: Severity.Minor,
// 一般,普通分支(失败不会阻塞流程,但会带来较大影响,如重要信息展示、异常情况返回问题)
3: Severity.Normal,
// 重要,重要分支(失败会阻塞重要分支流程)
4: Severity.Critical,
// 非常重要,核心流程(失败会阻塞主流程)
5: Severity.Blocker
};
// 状态枚举
const statusMap = {
// 通过测试
passed: Status.Passed,
// 测试中
pending: Status.Pending,
// 手动跳过测试,或使用 skip 跳过测试
skipped: Status.Skipped,
// 未通过测试
failed: Status.Failed,
// 待定
broken: Status.Broken,
// 使用 only、skip 跳过测试
unknown: 'unknown'
};
/**
* 请求报告
* @param {object} requestInfo 请求信息
*/
export function requestReporter(requestInfo) {
if (judgeCurrentTest()) {
reporter.description(requestInfo);
}
}
/**
* 参数报告
* @param {object|array} params 参数
*/
export function paramsReporter(params) {
if (judgeCurrentTest()) {
if (Object.prototype.toString.call(params) === '[object Object]') {
const labels = Object.keys(params);
labels.forEach((item) => {
reporter.addParameter('argument', item, JSON.stringify(params[item]));
});
} else if (Object.prototype.toString.call(params) === '[object Array]') {
reporter.addParameter('argument', 'params', JSON.stringify(params));
}
}
}
/**
* 断言报告
* @param {string} customMsg 断言信息
* @param {function} declare 断言
*/
export function expectReporter(customMsg, declare) {
if (judgeCurrentTest()) {
reporter.startStep(customMsg);
declare();
reporter.endStep(Status.Passed);
} else {
declare();
}
}
/**
* 设定用例优先级(重要程度)
* @param {number} level 优先级,1 微不足道,5 非常重要
*/
export function severityReporter(level) {
if (judgeCurrentTest()) {
reporter.severity(levelMap[level]);
}
}
/**
* 设定用例的用户场景分组
* @param {array} scenes 三级场景描述
* 一级场景:当前页面
* 二级场景:当前操作
* 三级场景:操作后的影响
*/
export function behaviorsReporter(scenes = []) {
if (judgeCurrentTest()) {
if (scenes[0] && scenes[1] && scenes[2]) {
reporter.epic(scenes[0]).feature(scenes[1]).story(scenes[2]);
} else if (scenes[0] && scenes[1]) {
reporter.epic(scenes[0]).feature(scenes[1]);
} else if (scenes[0]) {
reporter.epic(scenes[0]);
}
}
}
/**
* 跳过当前测试
* @param {object} conditions 判断条件
*/
export function skipTest(conditions) {
if (judgeCurrentTest()) {
let labels = Object.keys(conditions);
let voids = '';
for (let i = 0; i < labels.length; i++) {
if (
!conditions[labels[i]] ||
JSON.stringify(conditions[labels[i]]) === '{}' ||
JSON.stringify(conditions[labels[i]]) === '[]'
) {
voids += ` ${labels[i]}: ${JSON.stringify(conditions[labels[i]])} `;
}
}
if (voids) {
// 输出判断条件作为 description
reporter.description(voids);
reporter.allure.endCase(statusMap['skipped'], {
message: reporter.allure.getCurrentSuite().name
});
return true;
}
}
}
/**
* 当前用例不在测试范围,自动设置 skip
*/
export function autoSkip() {
if (judgeCurrentTest()) {
reporter.allure.endCase(statusMap['skip'], {
message: '不在当前测试范围的用例'
});
}
}
/**
* 将测试结果设置为 broken
*/
export function brokenTest() {
if (judgeCurrentTest()) {
reporter.allure.endCase(statusMap['broken'], {
message: reporter.allure.getCurrentSuite().name
});
}
}
/**
* 添加环境信息(展示用)
* @param {string} label 信息名
* @param {string} value 信息值
*/
export function addEnvironment(label, value) {
if (judgeCurrentTest()) {
reporter.addEnvironment(label, value);
}
}
/**
* 判断当前是否处于 test/it 语句中(否则 reporter 会报错)
*/
function judgeCurrentTest() {
return reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest();
}
4.5.2 请求方法封装
// httpService.js
import request from 'request';
// 自动登录和获取token逻辑,在此不做赘述
import { webLogin } from './login';
// 用户账号和服务配置,在此不做赘述
import { serviceConfig } from '@/config/testConfig/paramsConfig';
// reporter 文件的详细配置请参考 4.5.1
import {
requestReporter,
paramsReporter,
expectReporter,
brokenTest
} from '@/utils/testUtils/reporter';
let cookies;
/**
* web 端对外暴露的 http 服务
* @param {string} url 接口
* @param {object} params 参数
* @param {string} method 方法,默认 post
* @returns {object} promise
*/
export function httpService(url, params, method = 'post') {
// 若无 token 则先走登录逻辑
if (!cookies) {
return webLogin(serviceConfig).then((res) => {
cookies = res;
return service(serviceConfig.server + url, params, method, cookies);
});
} else {
return service(serviceConfig.server + url, params, method, cookies);
}
}
/**
* http 服务实体,基于 request 模块封装
* @param {string} url 接口
* @param {object} params 参数
* @param {string} method 方法
* @param {string} cookies cookies
*/
function service(url, params, method, cookies) {
return new Promise((resolve, reject) => {
// request.debug = true;
// 封装 get 请求的 query 参数
let query = '';
if (requestMethod === 'get' && params) {
Object.keys(params).forEach((item, index) => {
query += index === 0 ? '?' : '&';
query += item + '=' + params[item];
});
}
// 有种 cookie 需要传对象结构,所以做了区分
const headers =
Object.prototype.toString.call(cookies) === '[object Object]'
? {
'Content-Type': 'application/json',
...cookies
}
: {
'Content-Type': 'application/json',
cookie: cookies
};
request(
{
url: url + query,
method: method,
headers: headers,
body: JSON.stringify(params)
},
function (error, res, body) {
let data = body;
// 尝试 json 格式转换
try {
data = JSON.parse(body);
} catch (err) {}
// 输出请求日志、参数信息
const requestInfo = JSON.stringify({ url, params, data });
requestReporter(requestInfo);
paramsReporter(params);
if (!error && res.statusCode === 200 && global.checkFailed) {
// 校验 success:false 接口
expectReporter('接口请求失败', () => {
expect(data.success).toBeFalsy();
});
reject(data);
} else if (!error && res.statusCode === 200 && !global.checkFailed) {
// 正常返回接口
expectReporter('接口请求成功', () => {
expect(data.success).toBeTruthy();
});
resolve(data.data);
} else if (error || res.statusCode !== 200) {
// 请求失败
brokenTest();
reject(data);
}
// 允许请求失败参数,用于校验异常情况的错误返回
global.checkFailed = false;
}
);
});
}
4.6 测试文件执行
4.6.1 进程文件
import inventoryService from '@/api/service/inventory';
// 通过校验规则获取随机值的方法,属于业务封装,在此不做赘述
import { getRandomDataByRules } from '@/utils/testUtils/methods';
// 校验必要字段的方法,属于业务封装,在此不做赘述
import { requiredDeclare } from '@/utils/testUtils/declare';
// reporter 文件的详细配置请参考 4.5.1
import { expectReporter } from '@/utils/testUtils/reporter';
// 数据层配置项请参考 4.4.2
import {
detailFields,
intoLocationValidate
} from '@/views/.../intoLocationFields';
/**
* 获取库存信息
* @param {object} orderInfo 订单信息
*/
export function getIntoLocationDetails(orderInfo) {
const params = {
orderId: orderInfo.orderId
};
return inventoryService.detail(params).then(
(res) => {
expectReporter('库存数量大于0', () => {
expect(res.count).toBeGreaterThan(0);
});
requiredDeclare(
detailFields(),
res
);
return res;
},
() => {}
);
}
/**
* 进行入库操作
* @param {object} detailData 订单详情
* @param {object} specifiedData 指定的入参数据,用于校验异常情况
*/
export function intoLocation(detailData, specifiedData = {}) {
const params = {
amount: detailData.orderAmount,
amountSave:
specifiedData.amountSave ||
getRandomDataByRules(intoLocationValidate.amountSave(detailData)),
orderId: detailData.orderId,
stockType: getRandomDataByRules(intoLocationValidate.stockType())
};
return inventoryService.add(params).then(
() => {
return params;
},
() => {}
);
}
4.6.2 用例文件
import {
getIntoLocationDetails,
intoLocation
} from './process/intoLocation';
// 生成订单、清除订单状态进程,涉及业务在此不做赘述
import { generateOrder, clearStatus } from './process/common';
// reporter 文件的详细配置请参考 4.5.1
import { expectReporter } from '@/utils/testUtils/reporter';
// describe、it 等变量的定义请参考 4.5.1
describe.custom('获取库存信息-进行入库操作-库存数量减少', (descData) => {
// 生成订单
beforeAll(() => {
return generateOrder().then((res) => {
descData.orderParams = res;
});
});
it.custom(
'库存信息字段正常,获取库存信息供入库使用',
() => {
return getIntoLocationDetails(descData.orderParams).then((res) => {
descData.detailData = res;
});
},
['orderParams'],
5,
['全部入库']
);
it.custom(
'全部入库',
() => {
return intoLocation(descData.detailData).then(
(res) => {
descData.intoLocationParams = res;
}
);
},
['detailData'],
5,
['全部入库']
);
it.custom(
'库存数量发生改变',
() => {
return getIntoLocationDetails(descData.orderParams).then((res) => {
expectReporter('库存数量=原库存数量+入库数量', () => {
expect(Number(res.inventoryAmount)).toEqual(
Number(descData.detailData.inventoryAmount) +
Number(descData.intoLocationParams.amountSave)
);
});
});
},
['orderParams', 'detailData', 'intoLocationParams'],
5,
['全部入库', '数据变化']
);
// 两次入库数量
let intoLocationCount = [1, 2];
it.eachCustom(intoLocationCount)(
'第%d次入库',
(count, index) => {
// global.checkFailed = true;
const specifiedData = {
amountSave: count
};
return intoLocation(
descData.detailData,
specifiedData
);
},
['detailData'],
5,
['两次部分入库']
);
// 结束测试后恢复订单状态,不恢复的话业务上会占用其他资源
afterAll(() => {
return clearStatus();
});
});
5.效果展示
5.1 测试报告
5.1.1 总览
5.1.2 测试结果分类
5.1.3 测试用例展示
5.1.4 历史数据图表
5.1.5 测试时间轴
5.1.6 测试用例分组(根据场景)
5.2 测试覆盖率
5.2.1 表格总览
5.2.2 代码执行统计
6.总结
6.1 优势
与前端代码结合
- 投入产出比高,迭代成本低
- 测试层与业务层共用了
API
层和数据层,涉及到API
和数据的改动,只需要改一处即可两边生效 - 直接在代码开发过程中,同步进行测试进程编写,然后根据用例对进程进行组合生成用例
- 相较于传统自动化测试,开发周期更短(有效精简人员)
- 测试层与业务层共用了
- 难度可预见性
6.2 局限性
最后
本来想搞个实际案例的 git
项目出来分享的,后来考虑到接口测试还要后端支持,光靠前端也跑不起来,就暂时搁置了。后面有时间的话再补吧(先立个 flag
在这里了┗( ▔, ▔ )┛)。
还有就是,要是上面案例中有什么不对的地方,或者有更好的解决方案的,欢迎指正!
另外,如需转载此文章,还请附上此出处,算是对我花大半个月踩坑和总结的认可吧!