环境依赖
使用的主要的库
由于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!'
在我们的项目中使用
- 首先安装babel-plugin-rewire-exports
npm i -D babel-plugin-rewire-exports
- 在.babelrc中配置,使用该插件
{
"env": {
// 判断环境为单测环境时使用rewire插件来stub import的依赖,否则会导致打包编译出的代码出问题
"test": {
"plugins": ["rewire-exports"]
}
}
}
- 在测试文件中通过以下方式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深比较断言复杂类型的指南