基于 vue/jest/allure 的前端接口集成测试(下篇)

发表于:2020-9-27 09:50

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:奔跑的狗子    来源:掘金

  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 业务相关依赖
  Randexp:根据正则随机生成字符
  Uuidjs:生成 uuid
  通过 cdn 引用的其他组件、库,需要下到 node_modules
  3.2 配置文件
  3.2.1 package.json
  环境配置
  cross-env TEST_ENV=jest:设置环境变量 TEST_ENV 为 jest
  在区分调用业务层请求方法和测试层请求方法中会有用到
  脚本执行思路
  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 指令
  基于 allure-result 测试结果启动一个 web 服务器展示测试报告网页
  1. `testReporters/allure-results`:源数据文件
  综上所述 -- 主要使用以下几个脚本
  执行 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.6.1 )
  一个进程方法对应一个接口,对应一个接口的行为,不同的行为使用不同的进程方法
  通用的断言在进程方法中执行
  接口用例通过调用进程方法执行(具体请参考 4.6.2 )
  1. 一个接口用例对应一个进程方法
  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 表格总览
  展示的为 API 层的文件统计
  5.2.2 代码执行统计
  6.总结
  6.1 优势
  与前端代码结合
  投入产出比高,迭代成本低
  测试层与业务层共用了 API 层和数据层,涉及到 API 和数据的改动,只需要改一处即可两边生效
  直接在代码开发过程中,同步进行测试进程编写,然后根据用例对进程进行组合生成用例
  相较于传统自动化测试,开发周期更短(有效精简人员)
  难度可预见性
  根据业务代码的开发经验可以有效预估脚本开发周期、开发难度
  6.2 局限性
  测试用例的输出专业性不足
  数据层需要一套完善的方案来使测试层和业务层共用
  最后
  本来想搞个实际案例的 git 项目出来分享的,后来考虑到接口测试还要后端支持,光靠前端也跑不起来,就暂时搁置了。后面有时间的话再补吧(先立个 flag 在这里了┗( ▔, ▔ )┛)。
  还有就是,要是上面案例中有什么不对的地方,或者有更好的解决方案的,欢迎指正!

  上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号