1. `testReporters/allure-results`:源数据文件 |
{ "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", } } |
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); } } |
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); }); } |
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: {} }; |
module.exports = { env: { // 添加测试环境的配置 test: { plugins: [ [ '@babel/plugin-proposal-object-rest-spread', { loose: true, // 使用 Babel's extends helper 的对象拓展运算符 useBuiltIns: true // 直接将 拓展运算符 转换成 Object.assign } ] ] } } }; |
module.exports = { env: { browser: true, es6: true, node: true, amd: true, // 支持 jest 语法 jest: true }, globals: { // 支持 reporter 全局变量 reporter: false } }; |
1. 一个接口用例对应一个进程方法 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(当前页面) ... |
// 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); } |
// 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; |
import addressService from '@/api/addressService'; ... let params = { area: 11 }; addressService(params).then((res)=>{ let data = res; ... }); ... |
/** * 订单详情字段 * @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: '请选择入库类型' // 必填 }; } }; |
// 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] || JSON.stringify(conditions[labels]) === '{}' || JSON.stringify(conditions[labels]) === '[]' ) { voids += ` ${labels}: ${JSON.stringify(conditions[labels])} `; } } 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(); } |
// 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; } ); }); } |
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; }, () => {} ); } |
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(); }); }); |
欢迎光临 51Testing软件测试论坛 (http://bbs.51testing.com/) | Powered by Discuz! X3.2 |