51Testing软件测试论坛

标题: 基于 vue/jest/allure 的前端接口集成测试(下篇) [打印本页]

作者: 草帽路飞UU    时间: 2022-8-9 15:19
标题: 基于 vue/jest/allure 的前端接口集成测试(下篇)
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: {}
};


 

作者: 草帽路飞UU    时间: 2022-8-9 15:25
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: '请选择入库类型' // 必填
    };
  }
};


 











作者: 草帽路飞UU    时间: 2022-8-9 15:29
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] ||
        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();
}


 

作者: 草帽路飞UU    时间: 2022-8-9 15:41
本帖最后由 草帽路飞UU 于 2022-8-12 15:00 编辑

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 总览
[attach]140864[/attach]
5.1.2 测试结果分类
[attach]140865[/attach]
5.1.3测试用例展示
[attach]140866[/attach]
5.1.4 历史数据图表
[attach]140867[/attach]
[attach]140868[/attach]
5.1.5 测试时间轴
[attach]140869[/attach]
5.1.6 测试用例分组(根据场景)
[attach]140870[/attach]
5.2 测试覆盖率
  5.2.1 表格总览
  展示的为 API 层的文件统计

[attach]140871[/attach]
5.2.2 代码执行统计
[attach]140872[/attach]
6.总结
  6.1 优势
  与前端代码结合
  投入产出比高,迭代成本低
  测试层与业务层共用了 API 层和数据层,涉及到 API 和数据的改动,只需要改一处即可两边生效
  直接在代码开发过程中,同步进行测试进程编写,然后根据用例对进程进行组合生成用例
  相较于传统自动化测试,开发周期更短(有效精简人员)
  难度可预见性
  根据业务代码的开发经验可以有效预估脚本开发周期、开发难度
  6.2 局限性
  测试用例的输出专业性不足
  数据层需要一套完善的方案来使测试层和业务层共用
  最后
  本来想搞个实际案例的 git 项目出来分享的,后来考虑到接口测试还要后端支持,光靠前端也跑不起来,就暂时搁置了。后面有时间的话再补吧(先立个 flag 在这里了┗( ▔, ▔ )┛)。
  还有就是,要是上面案例中有什么不对的地方,或者有更好的解决方案的,欢迎指正!








欢迎光临 51Testing软件测试论坛 (http://bbs.51testing.com/) Powered by Discuz! X3.2