前端SDK单测指南及实战记录

1,169 阅读9分钟

环境依赖

使用的主要的库

由于SDK的测试是纯js函数的测试,所以不需要使用像enzyme、react-testing-library之类的涉及UI的库

  • mocha - js测试框架,用来管理测试用例
  • chai - 提供测试用断言库,验证执行结果是否符合预期
  • sinon -提供模拟依赖函数的功能
  • karma -测试时调用真实浏览器中以在真实浏览器环境中进行测试的工具库
  • Istanbul -生成测试覆盖率报告的库

package.json

"devDependencies": {
	"cross-env": "^7.0.3",
	"mocha": "^8.3.2",
	"chai": "^4.3.4",
    "chai-as-promised": "^7.1.1",
	"sinon": "^10.0.1",
	"karma": "^6.3.2",
    "karma-chai": "^0.1.0",
    "karma-chrome-launcher": "^3.1.0",
    "karma-coverage-istanbul-reporter": "^3.0.3",
    "karma-mocha": "^2.0.1",
    "karma-nyan-reporter": "^0.2.5",
    "karma-sinon": "^1.0.5",
    "karma-typescript": "^5.5.3",
    "karma-webpack": "^4.0.0",
	"istanbul-instrumenter-loader": "^3.0.1",
}
"scripts": {
    "test": "cross-env NODE_ENV=test npx karma start",
}

基本语法

mocha——基本框架

describe('hooks', function() {
  // 测试区块
  before(function() {
    // 在所有测试用例执行前执行的语句
  });

  after(function() {
    // 在所有测试用例执行后执行的语句
  });

  beforeEach(function() {
    // 在每次执行测试用例前执行的语句
  });

  afterEach(function() {
    // 在每次执行测试用例后执行的语句
  });

  // 单个测试用例
  it('测试xxx', function() {
    // 测试用例代码
  });
});

chai ——断言

expect(/** 希望测试的函数调用 */).to.be.euqal('xxx') //期望测试的函数输出等于xxx
expect(/** 希望测试的函数调用 返回值为对象 */).to.be.have.property('xxx')//期望测试的函数输出的对象有xxx属性
expect(/** 希望测试的函数调用 返回值为对象 */).to.be.deep.equal({xxx})//期望测试的函数输出的对象和预期对象深对比
expect(/** 希望测试的函数调用 返回值为true */).to.be.true//期望测试的函数输出结果为true

// 使用chai-as-promise拓展chai对promise的断言
expect(/** 希望测试的函数调用 返回值为promise */).eventuallly.to.be.rejected // 期望测试函数返回的promise为rejected状态

// 更多语法请参考chai官方文档 https://www.chaijs.com/api

sinon ——模拟函数

sinon的核心函数主要有 spy、stub、mock,其中最常用的为stub,其次为spy,基本用法如下:

  • spy ,可以记录被包裹的函数的各种调用信息,包括但不限于参数、返回值和抛出的异常等的获取

    const spy = sinon.spy(myFunc);// 用spy包裹myFunc函数用以监听函数行为
    myFunc('xx')// 执行myFunc
    expect(spy.calledWith('xx')).to.be.true //spy.calledWith('xx') spy封装的函数是否带着'xx'参数被调用
    expect(spy.calledOnce).to.be.true //spy.calledOnce是一个boolean值,表示spy封装的函数是否被调用过
    
  • stub ,拥有spy所有功能的同时可以改变存根函数的行为,使业务代码调用存根函数时按照测试预设的结果调用,只能对对象的属性进行存根

    const methodStub = sinon.stub(object, "method");// 用存根函数替换object.method,method必须是方法
    methodStub.returns('xxx')// 更改被存根的函数返回值为'xxx'
    methodStub.value(undefined)// 更改被存根的函数的值为undefined
    methodStub.restore() // 还原原始函数
    

spy和stub的区别在于是否想mock掉业务函数,如果仅需要监听某函数则用spy,如果想mock函数的返回值则用stub

  • mock spy和stub的结合体,可以监听一个对象的所有方法或者其中一个方法,有预编程(pre-programmed)的行为和期望(不常用)

    var myAPI = { method: function () {} };// 被监听对象
    var mock = sinon.mock(myAPI);
    mock.expects('method').once().throws();// 对myAPI的method方法设置被调用一次的期望
    myAPI.method()// 执行操作
    mock.verify();// 验证期望
    
  • 更多语法可以参考sinon官方文档

测试基本思路

我们做单元测试,顾名思义就是要以单元的粒度对代码进行测试,那么我们进行测试时需要对代码的哪些方面进行测试?基本思路是哪些?如何验证代码行为符合预期?根据对sdk单测经验的总结,一般来说可以从三个方面来对代码进行测试验证:

测试函数输出

测试函数的输出和我们的预期是否相同,最基本的测试

// getOutput.js
export function getOutput(): Language[] {
  return 1
}

// test.spec.js
it('测试返回值符合预期', () => {
	expect(getOutput()).equal(1);
});

测试函数修改行为

当被测函数调用时修改了闭包、全局变量等函数体外的变量时,我们可以去测试函数调用时对外部变量的修改是否符合预期,比如设置cookie

// setCookie.js
export function setUserInfo(): Language[] {
 //  ... 
  JS_COOKIE.set('userID','xxx')
}

// test.spec.js
it('Cookie设置为xxx', () => {
  const langList = setUserInfo();
	expect(JS_COOKIE.get('userID')).equal('xxx');
});

测试函数副作用

当被测函数调用时产生了异步请求或者打印日志、抛出异常等副作用时,对这些功能点的测试通常不关心其执行结果,只需要去验证是否调用了相应的功能即可

// getScript.js
export function getScript(): Language[] {
   return new Promise((resove, reject) => {
    // ...
     if(xxxx) reject(err)
  });
}

// test.spec.js
it('Cookie设置为xxx', () => {
  expect(getScript()).eventuallly.to.be.rejectedWith(err)
});

思路总结

对于一个函数的测试,大致上就是在这三方面进行测试,着重需要测哪部分是需要根据函数的特性、用法来因地制宜的选择。比如api请求类的函数测试,主要是测在给定后台数据的情况下,数据处理返回的数据是否正确,直接测返回值。除此之外,也会需要测错误处理是否达到预期,即是测函数的副作用。如果处理的同时涉及了cookie的设置,还需要测函数对函数以外作用域的修改行为是否达到预期。综上,对于复杂的函数,也需要根据情况进行多方面的测试,建议测试时根据覆盖率报告,来一步步增加测试用例的覆盖范围。

实战记录

前言

对于直接断言返回值的函数来说非常简单,在此不做赘述。 一般需要花心思的测试都是测试带有副作用的函数,对于此类函数,需要善用sinon来mock部分函数,以此断言我们想要的结果

基本测试

控制台打印

最基础的副作用测试,通过sinon来stub console函数,来判断函数是否调用了console以覆盖分支

// testFunction.ts
export function printLog(title) {
  if (console.group) {
    console.group('%c' + title, 'color: red;font-size: 16px;');
  }
 // ... 
}

// utils.spec.ts
describe('function-test', function () {
  it('测试console.group函数的调用', function () {
   	const groupStub = sinon.stub(console, 'group');// 存根
    testFunction.printKeywordLog('test');// 调用printKeywordLog函数
    expect(groupStub.calledWith('%ctest', 'color: red;font-size: 16px;')).to.be.true;// 断言group函数带着预期参数被调用
    groupStub.restore();// 将存根的group函数恢复,以免影响后续测试
  });
  
}
Axios异步请求

最常见的测试后台数据处理逻辑功能,通过sinon.stub将axios.post方法mock掉,更改其返回值为预设的后台数据,使得原函数执行到post语句时会直接返回一个带有后台数据的promise,以达到模拟后台发送数据的效果

// api.ts
import axios from 'axios'
export async function customRequest(body){
  // ...
  let resp: any = await axios.post(url, reqData)
	// ...
  return result;
}

// api.spec.ts
describe('api', function () {
  it('测试axios返回值符合预期', async function () {
    const axiosStub = sinon.stub(axios, 'post');// 将Axios实例的post方法存根
    const res = { code: 0, data: { Response: { data: 1 } } };// 定义后台请求的返回值
    axiosInsStub.resolves(res);// 将存根的axios的post方法设置为直接resolve我们设置的返回值
    const result = await yApiRequest();// 测试请求
    expect(result).to.be.deep.equal({ res: { data: 1 }, err: undefined });// 断言请求的返回值符合预期
    axiosStub.restore()// 将Axios实例的post方法恢复
  });
}
Axios拦截器interceptors

由上面测试axios请求的用例不难看出,测试时其实没有发出真实请求,而是通过stub了axios实例的post方法使其直接返回带有预设数据的resolve的Promise。因此当我们设置了拦截器时,因为请求没有发出的原因,拦截器是测试不到的。 而分析其axios拦截器的源码可得:axios的拦截器是将自定义的处理函数推入axios实例中interceptors属性下的handlers数组,所以在相应的测试用例中引入实例化并添加了拦截器的axios,直接调用其handlers数组中的函数并将参数传入其中,然后断言拦截器的返回值以覆盖测试即可

//api.ts
axiosIns.interceptors.response.use(
  (response) => {
    return response.data;
);
    
// axiosInterceptors.spec.ts
describe('api-test', function () {
  it('测试axios的response拦截器符合预期', function () {
    expect(axiosIns.interceptors.response.handlers[0].fulfilled({ data: 'foo' })).equal('foo');// 直接调用存在其中的拦截器
  });    
}

复杂测试

获取url参数
  • 问题描述:在某函数测试中,一个测试分支是通过location.search获取对应的参数,而karma调起的测试环境url是固定的,如果直接修改location.href会导致页面刷新,karma报错
  • 解决方案:通过使用 h5的history对象,用pushState方法修改url添加search参数的同时不刷新页面,从而能使函数获取到自定义的location.search完成测试
// getQuery.ts
export function getQueryParameters(str){
  return window.location.search
}

// getQuery.spec.ts
describe('getQuery', () => {
  it('测试获取参数', () => {
    const href = window.location.href;// 获取当前的href
    history.pushState({}, '', `${window.location.href}?query=test`);// 将search参数拼接后通过pushState修改地址,可以防止页面刷新karma报错
    expect(getQuery()).equal('test');// 断言获取到的search参数是否符合预期
    history.pushState({}, '', `${href}`);// 恢复原来的href以免影响后续测试
  });
});
对依赖模块(非npm包)的mock

对于待测函数中用到了其他相对路径的模块中的函数时,sinon是不能对其进行模拟的,在遇到这种情况时,如果我们需要对其进行模拟,则需要另外的库来完成目标——babel-plugin-rewire-exports

官方例子

//------ text.js ------
export let message = 'Hello world!'

//------ logger.js ------
import {message} from './text.js'

export default function () {
  console.log(message)
}

//------ main.js ------
import {rewire$message, restore} from './text.js'
import logger from './logger.js'

logger() // 'Hello world!'
rewire$message('I am now rewired')
logger() // 'I am now rewired'
restore()
logger() // 'Hello world!'

在我们的项目中使用

  1. 首先安装babel-plugin-rewire-exports
npm i -D babel-plugin-rewire-exports
  1. 在.babelrc中配置,使用该插件
{
"env": {
    // 判断环境为单测环境时使用rewire插件来stub import的依赖,否则会导致打包编译出的代码出问题
    "test": {
      "plugins": ["rewire-exports"]
    }
  }
}
  1. 在测试文件中通过以下方式mock源文件中使用的模块即可
//------ text.js ------
export const message =  () => 'Hello world!'

//------ logger.js ------
import {message} from './text.js'

export default function () {
  console.log(message())
}

//------ main.js ------
import {rewire$message, restore} from './text.js'
import logger from './logger.js'

logger() // 'Hello world!'
const messageStub = sinon.stub.returns("I am now rewired")
rewire$message(messageStub)// 将依赖函数替换成我们新建的指定返回值的sinon函数
logger() // 'I am now rewired'// logger中调用依赖的message函数输出替换为了我们自定义的函数
restore()
logger() // 'Hello world!'// 调用restore后恢复
对axios.create的实例进行mock

在某些函数中,通过axios.create创建的实例来发送请求而不是通过从其他模块import的方式,sinon是无法对其进行存根的

//api.ts
import axios from 'axios'
export async function customRequest(body){
  // ...
  const axiosInstance = axios.create(...)
  axiosInstance.post({...})
  // ...
}

it("api-test", function () {
    const postStub = sinon.stub().resolves({ data: { code: "0" } });
    // 通过mock axios的create方法,使其返回一个包含post属性的对象,其值为我们自订返回值的空函数
    const axiosStub = sinon.stub(AXIOS, "create").returns({ post: postStub });
    api();
    expect(xxx)
    axiosStub.restore();
  });

拓展阅读:

Best Practices for Spies, Stubs and Mocks in Sinon.js:关于sinon实践的文章

How to Test NodeJS Apps using Mocha, Chai and SinonJS:Mocha、Chai、Sinon组合使用的文章

單元測試:Mocha、Chai 和 Sinon:Mocha、Chai、Sinon组合使用的实践文章

Testing Arrays and Objects with Chai.js:用chai深比较断言复杂类型的指南