基于 vue/jest/allure 的前端接口集成测试

1,973 阅读22分钟

前言

前些时间,应公司业务需求,想在 vue 项目中,利用 jest 框架做一下接口的集成测试。

但是网上一查呢,利用 jest 做接口测试的案例很少(就连前端测试的 jest 项目案例都很少,这里指实际项目,不是 demo 的那种)。而且经过 jest 官方文档的查阅,发现接口测试这一块需要的一些功能要求,光靠 jest 也很难满足(具体见文章 2.1 功能要求)。

前期一直在 jest 里摸索,想着曲线救国,弯道完成功能实现,但是效果甚微。一直到发现了 allurejest-allure )工具,才给了我一些启示和思路,实现了弯道超车。关于网上 allure 的实际案例么,就更少了,就连官方文档就不是特别友好。经过我个人的翻阅各路资料,加上实际踩坑摸索,才总结了下面一套实际的开发案例,个人感觉还是蛮宝贵的,所以拿出来跟大家分享一下。

第一部分是测试方法和思路设计,跟项目案例无关,只是个人对产品测试的一些总结(这个项目案例应该算灰盒测试了),不感兴趣的同学可以直接跳到第二部分。第二部分是项目案例的功能点拆解,以及每一个功能点的实现思路,方便针对单独的功能点进行查阅。第三部分开始就是项目的完整改造了(因为想尽可能地写完整,所以内容可能会有点长哈^_^)。

1.测试方法和思路设计

1.1 灰盒测试

1.1.1 概念

思想

基于程序运行时的外部表现,同时结合程序内部逻辑结构来设计测试用例

方式

通过执行程序、采集程序路径执行信息、获取外部用户接口结果,来进行测试

目的

旨在验证软件满足外部指标,并且对软件的所有通道或路径都进行了检验

时机

执行时机通常在开发者做完白盒测试之后,在功能测试人员进行大规模集成测试之前

区别
  • 通过编写代码、调用函数或者封装好的接口进行测试(白盒)
  • 但无需关心程序内部的实现细节(黑盒)

1.1.2 方法和步骤

  1. 确定程序的所有输入和输出;
  2. 确定程序所有状态;
  3. 确定程序主路径(主流程);
  4. 确定程序的功能(所有分支);
  5. 产生试验子功能 X 的输入,这里 X 为众多子功能之一;
  6. 制定验证子功能的 X 的输出;
  7. 执行测试用例 X 的软件;
  8. 检验测试用例 X 结果的正确性;
  9. 对其余子功能,重复(7)和(8);
  10. 重复(4)~(8),然后再进行(9),进行回归测试。

1.2 测试思路设计

1.2.1 前置操作

  • 根据场景生成对应数据,不深入测试

1.2.2 场景提炼

  • 根据流程场景划分测试用例
  • 执行对当前流程有影响的重要分支操作
  • 对当前流程有影响的参数,同时进行不合规测试
  • 当前用例不通过的,直接跳过后面的用例

1.2.3 断言

  • 只断言对业务流程有影响的字段的值

1.2.4 后置操作

  • 状态归位,释放资源,不深入测试

1.3 一些测试策略

  • 互不影响的功能点可以放入同一个用例中进行测试
  • 互不影响的分支可以放入同一个用例中进行测试,A1 中加入 B1A2 中加入 B2A3 中不需要再测试 B1B2
  • 基础配置,不参与测试,但需要单独维护一套可用配置,以供流程执行
  • 以用户常用的操作流程为主设计用例,不需要所有情况全部覆盖,异常测试也只需要执行用户常见的异常

2.整体思路

名词说明
  • 测试文件:指 file.test.js
  • 测试用例:指 describe
  • 接口用例:指 it

2.1 功能要求

2.1.1 核心功能

  1. 接口用例串行测试(非并行)
    1. 同个测试文件内不同接口用例依次进行测试
    2. 不同测试文件依次进行测试
  2. 前面接口用例生成的数据供后面接口用例使用(同个测试文件的同个测试用例中)
  3. 多次执行某个接口用例
  4. 前置操作、后置操作
  5. 断点调试
  6. 跳过某条接口用例
    1. 比如上一个接口失败,导致此接口所需的必要参数为空,那么此接口用例应直接跳过

2.1.2 辅助功能

  1. 接口信息展示(包括接口信息,出入参)
  2. 断言展示
  3. 接口用例的优先级分类展示
  4. 接口用例的测试结果分类
    1. 如成功、失败、跳过等结果
  5. 接口用例本身分组
    1. 根据接口用例的用户行为或其他依据进行划分
  6. 环境信息展示
  7. 历史数据统计信息展示
  8. 测试覆盖率展示

2.1.3 项目代码要求

  1. API 层执行统一的出入参数据处理,共同服务于业务层和测试层
  2. 将数据层从业务层抽离,共同服务于业务层和测试层

2.2 实现思路

2.2.1 接口用例串行测试(非并行)

同个测试文件内不同接口用例依次进行测试
  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 文件)
  1. 使用 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 / beforeEachafterAll / 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 断点调试

可以使用 vscodenode.js debug terminal 直接调试
图片.png

2.2.6 跳过某条接口用例

  1. 新定义 it 方法,在不满足特定条件时不调用 functionit 的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
    1. 这样 it 内的函数体就不会执行
  2. 使用 allureendCase 方法将用例状态手动改为 skippedreporter 的完整封装请参考 4.5.1 )
    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 接口信息展示

  1. 使用 allure reporterdescription 方法输出接口信息( 请求方法的完整封装请参考 4.5.2 )
    1. 需要 jest-allure 依赖支持
  2. 使用 allure reporteraddParameter 方法输出参数信息( reporter 的完整封装请参考 4.5.1 )
    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 断言展示

  1. 重新封装断言的调用方式,每个断言都传入断言信息
  2. 使用 allure reporterstartStependStep 方法输出断言信息
    1. 需要 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 接口用例的优先级分类展示

  1. 在新定义的 it 方法中增加优先级分类展示的逻辑( it 的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
  2. 使用 allure reporterseverity 方法设定接口用例的优先级
    1. 由不重要到重要依次为 trivialminornormalcriticalblocker
// 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 接口用例的测试结果分类

  1. 用例结果的五种状态
    1. passed:接口用例成功
    2. failed:接口用例失败
    3. broken:接口请求失败
    4. skipped:跳过接口用例
    5. unknown:其他异常情况
  2. 使用 allureendCase 方法手动修改用例的结果状态
    1. 在自定义 it 方法、自定义 it.skip 方法、http 服务封装中调用,分别设置 skippedbroken 的结果状态( it 的完整封装请参考 4.5.1,http 服务的完整封装请参考 4.5.2, reporter 的完整封装请参考 4.5.1 )
    2. 其他 failed 状态通过自定义结果分类来过滤和区分
    3. 需要 jest-allure 依赖支持
  3. 自定义的结果分类
    1. 跳过测试:状态【skipped
      1. 包括根据条件手动跳过,以及使用 it.skip 自动跳过
    2. 请求失败:状态【broken
      1. 包括请求报错、返回非 200 状态码等(不包括请求超时)
    3. 断言失败:状态【failed】,错误匹配【含 declare
    4. 请求超时:状态【failed】,错误匹配【含 Timeout
      1. 确切的表达应该是断言超时,但断言一般消耗时间很短,更多是由请求超时导致的断言超时
    5. 代码报错:状态【failed】,错误匹配【不含 Timeoutdeclare
    6. 未知错误:状态【unknown
      1. 其他未识别的异常状态,包括使用 it.only 的跳过文件
  4. 配置自定义结果分类文件 categories.json
    1. 需要 jest-allure 依赖支持
    2. 生成 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 接口用例本身分组

  1. 使用 allure reporterepicfeaturestory 三个方法手动对用例进行场景分类( it 的完整封装请参考 4.5.1,接口用例的完整调用请参考 4.6.2 )
    1. epicfeaturestory 为三层级联关系的场景
      1. 同一级的相同场景的用例会放在一个组中
      2. 上级场景可以包含多个下级场景,用例可以放置在任意一级的场景下分组下
    2. 需要 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 环境信息展示

  1. 配置自定义环境信息 environment.xml
    1. 需要 jest-allure 依赖支持
    2. 生成 allure-report 前需要将 environment.xml 文件复制到 allure-result 目录下(复制文件的脚本请参考 3.2.1 )
  2. 也可以在接口用例中添加环境信息,需要使用 allure reporteraddEnvironment 方法添加
// 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 历史数据统计信息展示

  1. 需要在执行 jest 测试前,将 allure-report 目录下的 history 文件夹复制到 allure-results 目录下,作为历史数据统计的数据源(复制文件的脚本请参考 3.2.1 )
    1. 需要 jest-allure 依赖支持

2.2.14 测试覆盖率展示

  1. 使用 jest 指令时增加 --coverage 参数(具体 npm 指令配置请参考 3.2.1 )
//package.json
{
  "script": {
  	 "test": "jest --coverage"
  }
}

3.环境搭建

3.1 依赖安装

3.1.1 测试相关依赖

  1. @vue/test-utils:vue 的测试工具
  2. @vue/cli-plugin-unit-jest:vue 集成的 jest 工具,集成了以下内容
    1. vue-jest:解析 vue 语法
    2. jest-transform-stub:解析静态资源
    3. babel-jest:解析 js/jsx 语法
    4. jest-serializer-vue:用于快照测试
    5. jest-watch-typeahead/filename:监听 文件名 变化
    6. jest-watch-typeahead/testname:监听 文件名 变化
  3. @babel/plugin-proposal-object-rest-spread:node 环境下支持对象拓展运算符
  4. jest-expect-message:断言失败时的自定义信息
  5. jest-allure:生成可视化测试报告
  6. allure-commandline:支持 allure 指令

3.1.2 业务相关依赖

  1. Randexp:根据正则随机生成字符
  2. Uuidjs:生成 uuid
  3. 通过 cdn 引用的其他组件、库,需要下到 node_modules

3.2 配置文件

3.2.1 package.json

环境配置
  • cross-env TEST_ENV=jest:设置环境变量 TEST_ENVjest
    • 在区分调用业务层请求方法和测试层请求方法中会有用到
脚本执行思路
  1. allure-result:setup 指令
    1. 清空 allure-result 测试结果文件夹(如果存在的话)
    2. 复制 allure-report 测试报告文件夹中的 history 历史数据文件到 allure-result 文件夹中(如果存在的话)
      1. 用于生成测试报告的历史数据图表
  2. jest 指令
    1. 运行 jest 脚本进行测试
    2. --coverage 参数会同时生成测试覆盖率报告
  3. allure:setup 指令
    1. 生成测试报告的环境信息、用例分类信息文件
    2. 将两个文件复制到 allure-result 测试结果目录下,供测试报告生成时使用
  4. allure generate 指令
    1. 基于 allure-result 测试结果生成 allure-report 测试报告文件
      1. testReporters/allure-results:源数据文件
      2. --clean:删除已有的 allure-report 文件
      3. --output testReporters/allure-report:输出目录
  5. allure serve 指令
    1. 基于 allure-result 测试结果启动一个 web 服务器展示测试报告网页
      1. testReporters/allure-results:源数据文件
综上所述 -- 主要使用以下几个脚本
  1. 执行 npm run test :进行测试并生成 allure-result 测试结果文件
  2. 执行 npm run test:allure :基于 allure-result 测试结果文件生成 allure-report 测试报告文件,并开启一个测试报告的 web 服务
  3. 执行 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 改造思路

  1. API 层,请求实体方法区分(具体请参考 4.3.1 )
    1. 分为业务层、测试层的请求方法
  2. 数据层从业务层抽离
    1. 数据配置项、数据校验项独立存放(具体请参考 4.4.2 )
    2. 业务层、测试层共同使用
  3. 一些测试的公用方法封装
    1. 用例方法重写,如 itdescribeit.eachit.skip 等(具体请参考 4.5.1 )
      1. 为了加入额外的功能,如实现用例跳过、设置优先级、设置分组等
    2. 测试层的请求方法封装(具体请参考 4.5.2 )
  4. 测试文件的执行
    1. 进程文件控制每个接口的行为(具体请参考 4.6.1 )
      1. 一个进程方法对应一个接口,对应一个接口的行为,不同的行为使用不同的进程方法
      2. 通用的断言在进程方法中执行
    2. 接口用例通过调用进程方法执行(具体请参考 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 请求实体封装

  1. 需要区分业务层的 http 方法和测试层的 http 方法(具体封装逻辑请参考 4.5.2 )
    1. 测试层的 http 方法需要自动登录,业务层不需要
    2. 业务层使用 axios 封装请求,而测试层则需要使用 noderequest 模块封装请求
      1. 使用 axiosjsdom 环境( jest 的测试环境)中调用接口会报跨域问题
      2. 若改成 node 环境,项目中使用到 document 的文件(依赖)会报错
    3. 测试层的 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 封装

  1. 所有 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 业务层 & 测试层调用

  1. 业务层、测试层均调用同一个 API
    1. 保证出入参统一处理
    2. 后期迭代更新只用修改 API 层一处
import addressService from '@/api/addressService';
...
	let params = {
  	area: 11
  };
	addressService(params).then((res)=>{
  	let data = res;
    ...
  });
...

4.4 数据层

4.4.1 数据层从业务中抽离

  1. 与 业务层 解耦,形成独立配置文件
  2. 业务层、测试层共用一套数据逻辑
    1. 业务层作为数据配置
    2. 测试层作为字段测试、数据生成依据

4.4.2 数据层包括

  1. 展示类数据
    1. 如 table 数据、信息展示数据等
    2. 若存在与业务交互的场景,则交互模块可由业务层作为参数传入
    3. 定义 testFields 等字段,作为测试字段使用
  2. 校验类数据
    1. 如表单数据等
    2. 若存在与业务交互的场景,则交互模块可由业务层作为参数传入
    3. 自定义一套配置规则,供业务层、测试层共同使用
      1. 业务层解析为校验规则、长度限制、默认值、枚举项等信息
      2. 测试层解析并生成符合要求的随机数据
        1. 特殊情况可以传入自定义异常的参数(校验特定场景/校验异常情况等)
/**
 * 订单详情字段
 * @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 总览

图片.png

5.1.2 测试结果分类

图片.png

5.1.3 测试用例展示

图片.png

5.1.4 历史数据图表

图片.png
图片.png

5.1.5 测试时间轴

图片.png

5.1.6 测试用例分组(根据场景)

图片.png

5.2 测试覆盖率

5.2.1 表格总览

展示的为 API 层的文件统计
图片.png

5.2.2 代码执行统计

图片.png

6.总结

6.1 优势

与前端代码结合
  • 投入产出比高,迭代成本低
    • 测试层与业务层共用了 API 层和数据层,涉及到 API 和数据的改动,只需要改一处即可两边生效
    • 直接在代码开发过程中,同步进行测试进程编写,然后根据用例对进程进行组合生成用例
    • 相较于传统自动化测试,开发周期更短(有效精简人员)
  • 难度可预见性
    • 根据业务代码的开发经验可以有效预估脚本开发周期、开发难度

6.2 局限性

  • 测试用例的输出专业性不足
  • 数据层需要一套完善的方案来使测试层和业务层共用

最后

本来想搞个实际案例的 git 项目出来分享的,后来考虑到接口测试还要后端支持,光靠前端也跑不起来,就暂时搁置了。后面有时间的话再补吧(先立个 flag 在这里了┗( ▔, ▔ )┛)。

还有就是,要是上面案例中有什么不对的地方,或者有更好的解决方案的,欢迎指正!

另外,如需转载此文章,还请附上此出处,算是对我花大半个月踩坑和总结的认可吧!