说明
本文记录了内部团队为小程序探索、搭建基于jest框架单元测试的整个过程,关于单元测试的编写风格借鉴了很多优秀的文章中的观点【见附录】,测试框架的建立也从兄弟团队借鉴了很多的经验,所以也特别感谢兄弟团队的同学们提供的大量帮助。
导读
本文共计5000字,预计通篇阅读时长 10 min。
“探索“、和”落地”两个部分,主要讲述了我们探索小程序单元测试的思考过程。包含了我们是如何定义”什么是一个好的单元测试“,以及针对小程序的每个组成部分,哪些需要单元测试,哪些不需要,我们最后决定采用的测试策略是什么。
“实践”和“原理”两个部分,主要围绕“如何编写一个符合质量要求的单元测试”,以及“如何在微信小程序中引入基于jest框架的单元测试”这两个基础问题展开。
希望通过下文的阅读可以给你带来帮助。
探索
探索之初,只有一个大概的认知就是:我们需要一个比UI自动化测试更加贴近底层逻辑的测试工具,它是什么,我们怎么找到它,找到后要怎么落地,还是不太明确。
起初学习了小程序官方提供的单元测试库:miniprogram-simulate。尝试实践了一下,感觉整个库提供的功能更偏向组件的UI渲染测试,测试的结果是以渲染DOM为基础,测试DOM内容是否正确,DOM绑定的事件点击后一些改变是否正常,等等。总之这种测试感觉很无力,如果需要这么测,现在的UI自动化足够胜任。而且这个库作为官方推荐库,本身的API不是十分丰富,使用起来较为复杂,社区活跃度也不是十分的高,如果直接引入到生产环境,还是有很大风险的,所以并没有考虑使用这个。
不过,在这个尝试的过程中我们逐渐明确了,我们需要的是对于底层逻辑的单元测试,也就是说对于单个函数逻辑功能的测试,UI渲染方面的测试需求不是很大。特别是在小程序单元测试这方面的文档是空白的,所以我们决定将问题更加抽象一些,探索的方向拓宽为前端的单元测试该如何做,这样就可以参考各种框架中优秀单元测试的实践指南了。这期间看到了很多关于前端单元测试落地的文章,关于实践的思考成果也大多从这个阶段产出的。
什么是好的单元测试?
“什么才是好的单元测试”,以及“如何写出这样的单元测试?”这些问题是我们要解决的核心问题。通过查阅“React单元测试”的相关文章,我们找到了一些优秀的、要求明确的案例。下面,我们来看一个最简单的JavaScript单元测试长什么样
基本规则
通过观察显然可得,这个单元测试是一个三段式的,而且逻辑也比较严谨,描述清晰,可读性强。所以编写测试的思想一定是遵循这个given-when-then结构的,可以让你写出比较清晰的测试结构,既易于阅读,也易于维护。
例如:
首先准备一些测试用例数据【give】,
同时引入你想要测试的函数,并将你准备好的参数传入【when】
最后对函数执行的结果进行断言【then】
为了明确,你甚至可以先将结果写出来,
const expectResult = 1000
expect(result).toEqual(expectResult);
这种单元测试结构是基础,几乎所有函数的测试都可以通过这样一种范式来表达。有了这种范式,我们就可以制定一些标准,然后通过对细节做出描述,这样结合起来就得出了“什么才是好的单元测试”这个问题的答案。那么,除了这个基本的“骨架”,每一句语言的描述又有什么要求呢?
约束原则
如果说三段式的结构是一个人的骨架,那么这些约束原则,则是一个人的血肉,这二者结合,才是一副有血有肉的躯体。这些原则对于任何语言、任何层级的测试都适用。总结于此,既可以事前参考,提升自己的编写能力;又可以此为镜,时时检验你的单元测试套件是否高效:
- 只关注输入输出,不关注内部实现【重要】
只要测试输入没有变,输出就不应该变。这个特性,是测试支撑重构的基础
如果你开始关注被测函数的内部逻辑了,那么显然就这个测试是不可靠的,不能作为重构支持的,因为这种情况下你的单元测试好像已经变成了业务函数的一部分,不再能提供可靠的外部支持。
特别有一点需要注意:单元测试关注于内部函数的执行顺序也是一种关注内部实现的表现。
- 只测一条分支【重要】
通常来说,一条分支就是一个业务场景,是你做任务分解过程的一个细粒度的task。为什么测试只测一条分支呢?很显然,如此你才能给它一个好的描述,这个测试才能保护这个特定的业务场景,挂了的时候能给你细致到输入输出级别的业务反馈。
在我们的实际操作中,一条业务分支通常是由一个describe来划分的,在第一个参数的位置给出对于被测任务的描述。然后每一个测试点只测一个函数,通过尽可能丰富的参数来增强测试的健壮性。
常见的反模式是,单元测试本身就做了太多的事情,不符合SRP原则。单一职责原则(Single Responsibility Principle))。
- 表达力极强【次重要】
表达力强的测试,能在失败的时候给你非常迅速的反馈。它讲的是两方面:
① 看到测试时,你就知道它测的业务点是啥
② 测试挂掉时,能清楚地知道失败的业务场景、期望数据与实际输出的差异
总结起来,这些表达力主要体现在以下的方面:
测试描述。遵循上一条原则(一个单元测试只测一个分支)的情况下,描述通常能写出一个相当详细的业务场景。这为测试的读者提供了极佳的业务上下文
测试数据准备。无关的测试数据(比如对象中的很多无关字段)不应该写出来,应只准备能体现测试业务的最小数据
输出报告。选用断言工具时,应注意除了要提供测试结果,还要能准确提供“期望值”与“实际值”的差异
- 不包含逻辑【次重要】
跟写声明式的代码一样的道理,测试需要都是简单的声明:准备数据、调用函数、断言,让人一眼就明白这个测试在测什么。如果含有逻辑,你读的时候就要多花时间理解;一旦测试挂掉,你咋知道是实现挂了还是测试本身就挂了呢?
- 运行速度快【探索阶段,暂不考虑】
单元测试只有在毫秒级别内完成,开发者才会愿意频繁地运行它,将其作为快速反馈的手段也才能成立。那么为了使单元测试更快,我们需要:
尽可能地避免依赖。除了恰当设计好对象,关于避免依赖我已知有两种不同的看法:
使用mock适当隔离掉三方的依赖(如数据库、网络、文件等)
避免mock,换用更快速的数据库、启动轻量级服务器、重点测试文件内容等来迂回
将依赖、集成等耗时、依赖三方返回的地方放到更高层级的测试中,有策略性地去做。
由于没有特别关心这个方面,所以我没有了解这个部分,以后有机会会继续学习。
落地
明确了什么是好的单元测试,编写的过程应该遵循哪些规范,但是具体到小程序,我们该如何落地呢?针对不同的层次,我们又有哪些策略呢?
策略
架构中的不同元素有不同的特点,因此即便是单元测试,我们也有针对性的测试策略:
注:此处的“覆盖”问题指的是被测对象层面,即:每个函数都有对应的测试函数相佐,而非单个测试函数内部用例的覆盖情况。
说明:此处的页面类型划分并不是建议单元测试的组织方式,只是为了方便表述。从TDD的角度出发,测试一定是会持续维护的,所以单元测试 的组织方式,我感觉应该是需要和项目结构差不多的。
actions 测试
这一层获益于架构的简单性,小程序的action并不是成熟的React-redux,所以action的产生都是手动写出来的,没有工厂函数生成的,所以可以不用测试。
reducer 测试
reducer 大概有两种:
一种比较简单,仅一一保存对应的数据切片;
一种复杂一些,里面具有一些计算逻辑。
不管我们要写的是哪一种,结合我们的落地策略,我们关心的只有被测函数执行前的一个state,以及执行后的状态。所以give - when - then 在这个测试中的对应关系就很明确了。
describe('reducer相关功能', () => {
test('成功发布作品', () => {
// give 给出了对于结果的断言,以及想要执行的action。
const resultState = {
title: "测试作品标题",
...
videoBgmList: [{}, {}],
};
const action = { type: 'PUBLISH_WORKS_SUCCESS' };
const pervState = {}
// when 调用该reducer,对传入的prevState进行更改,根据测试需求的不同可以对prevState进行定制化处理
const newState = videoTool(pervState, action);
// then 对于执行的结果进行断言,断言的方式是多种多样的,此处给出的是按需比较,你也可以用整个对象的值进行比较
expect(newState.title).toEqual(resultState.title);
....
expect(newState.videoBgmList.length).toBeGreaterThan(0);
});
})
component 测试
component测试和page测试在测试方面的感觉是没有什么区别的,都是经过预先设置好的mock文件,mock好一些底层函数,然后针对性的对一些实现功能的核心函数进行调用测试。这些函数可能改变了页面data,也可能调用了其他的函数,这些我们都可行进行断言。所以整体来说,难度并不是很大。
下面的实践中讲解的用例就是对一个发布component的核心函数"发布"的测试。此处就不再赘述用例了。
实践
经过上面文章的洗礼,感觉自己信心满满,似乎已经习得了单元测试的精髓,那我们撸起袖子,亲自上手试试看吧。
目标函数
handlePostBtnTap() {
this.handlePrintStatistic('entrance');
const { sendDesc } = this.properties;
const buttonList = [
{
name: '发视频',
src: `./post_video.png`,
},
{
name: '发图片',
src: `./post_photos.png`,
},
];
this.setData({
actionSheet: {
hidden: false,
onCancel: 'handleHideAS',
buttons: buttonList,
},
});
},
仿照规则,编写测试
首先,依照目标,我想要测试推荐页的发布函数的代码(代码如上)。
- give:引入发视频函数。
- when:调用发视频函数
- then:该函数被调用后,当前页的data将会发生改变,某些关联函数将会被调用。
能走完这三个过程,一个发视频函数的单元测试就编写完了。看似简单,但是实际操作的时候,第一个坎儿就出现了
give
怎么引入我想要调用的函数到测试文件呢?
导入待测页面到当前的测试文件,通过global对象,取到当前component(或者app、page都可以),此时 global.wxComponentInstance 对象所代表的就是待测页面的页面对象。这样,我们就可以引入待测函数了。
为了方便,我们将 global.wxComponentInstance 赋值给components
引入待测函数以后,我们就该走下一步了,对函数进行测试:
when
如何进行函数的调用呢?
这个同样也是有办法的,例如,我们要测试的函数是 handlePostBtnTap ,那么我们就可以在当前页的 components 对象下找到这个函数,并将当前对象作为this传入,例如:
components.methods.handlePostBtnTap.call(components)
这样,我们就完成了目标函数的调用(当然你也可以选择直接调用,只是调用后页内各个函数的层级结构会有所不同,读者可以自行尝试,此处不进一步展开)。
then
至此,一个单元测试的三步,我们已经完成了两个步骤:give和when,then?
根据 handlePostBtnTap 逻辑,我们可以很明白的整理出:
- this.handlePrintStatistic 将会被调用。
- 当前页面的data中的actionSheet的值将会被改变。
所以,我们根据上面的整理结果,对函数运行后的结果进行断言:
expect(components.handlePrintStatistic).toBeCalled(); expect(components.data.actionSheet.buttons).toHaveLength(2);
两个日志函数将会被调用,当前页的data的actionSheet.buttons的长度将会变成个。
整理一下,我们完整的写一下当前的这个测试函数:
test('发布按钮点击', () => {
components.methods.handlePostBtnTap.call(components);
expect(components.handlePrintStatistic).toBeCalled();
expect(components.data.actionSheet.buttons).toHaveLength(2);
});
完善
好的,该做的工作我们做完了,应该准备运行了。
npm run test
回车!开始了!运行起来了!运行到了目标函数了!欸 ?报错了。。。
没错呀,写的没问题呀,为啥就报错呢?
我们先分析一下错误:
TypeError: this.handlePrintStatistic is not a function
显然,这个函数是被调用的其中一个日志函数。当我们调用被测试函数的时候,被测函数就会调用这些相关的函数,但是我们只引入了被测函数一个函数,所以这些相关函数当然要报错了。根据测试原则 只关注输入输出,不关注内部实现 我们将这些函数Mock掉,我们不关心他们具体做了什么,我们只关心他们是否被调用了(被调用也是被测函数的一种输出)。
所以,我们把这些无关的函数都Mock掉,然后,我们的测试函数就变成了这样:
test('发布按钮点击', () => {
components.handlePrintStatistic = jest.fn();
components.methods.handlePostBtnTap.call(components);
expect(components.handlePrintStatistic).toBeCalled();
expect(components.data.actionSheet.buttons).toHaveLength(2);
});
然后我们再运行一下我们的测试函数,
npm run test
回车!开始了!运行起来了!运行到了目标函数了!怎么又报错了。。。
老办法,继续分析错误:
TypeError: Cannot destructure property `sendDesc` of 'undefined' or 'null'.
const { sendDesc } = this.properties;
| ^
56 | const buttonList = [
57 | {
58 | name: '发视频',
有了上次的经验,我们已经知道了,调用这个函数的时候,没有this.properties,所以报错了,同样,这种我们并不关心的内部逻辑,直接Mock就可以了。
继续完善我们的单元测试代码如下:
test('发布按钮点击', () => {
components.methods.properties = {
sendDesc: ''
};
components.handlePrintStatistic = jest.fn();
components.methods.handlePostBtnTap.call(components);
expect(components.handlePrintStatistic).toBeCalled();
expect(components.data.actionSheet.buttons).toHaveLength(2);
});
然后我们再运行一下我们的测试函数,
npm run test
回车!开始了!运行起来了!运行到了目标函数了!成功了!
不出意外的话,你会看到如图所示的通过提示,至此,一个符合我们当前要求的单元测试就诞生啦!!!
当然,这只是最简单的函数的单元测试,但是如果想要体现我们的思想和基本要求,这就足够了。
以后可能会有更复杂的单元测试需要编写,但是它的基本原则是不变的,围绕基本原则编写,就可以写出优秀的单元测试。
原理
环境搭建
如果是一个全新的小程序,如何搭建一个单测框架?
当前项目采用的是手动配置的方式,在项目的package.json中进行必要的配置,只需三步,即可搭建好运行环境。
1、在工程的根目录下创建一个babel.config.js文件用于配置与你当前Node版本兼容的Babel:
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
plugins: ['transform-es2015-modules-commonjs'],
};
2、需要在测试环境引入一些依赖包(只在测试环境下进行单元测试),
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"babel-jest": "^24.9.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"jest": "^24.9.0",
"miniprogram-simulate": "^1.0.8"
}
3、添加scripts命令和jest基础配置
// scripts 中加入test命令,方便运行
"scripts": {
"test": "jest",
},
// 增加jest configuring,
// 具体可见:https://jestjs.io/docs/zh-Hans/configuration
"transform": {
"^.+\\.js$": "babel-jest"
},
"setupFiles": [
"./test/wx.js"
// 此处的文件路径是每个测试用例运行前都需要先执行的mock函数的存放文件
]
}
此处关于setUpFiles配置项再说一下:
运行一些代码来配置或设置测试环境的模块的路径列表。每个setupFile将针对每个测试文件运行一次。由于每个测试都在其自己的环境中运行,所以这些脚本将在测试环境中执行,然后立即执行测试代码本身。
在实际使用中,我们大部分项目测试前都需要Mock掉一些wx提供的函数,并且给getApp()等全局变量中赋初值,这些操作都可以在setUpFiles里面的文件中去做,下面我给出一些示例:
// 当前文件的路径是 /test/wx.js
// 每个测试文件都需要Mock的wx提供的内置函数,没有用到的可以删除,没有出现的可以补充
global.wx = {
chooseVideo: jest.fn(),
chooseImage: jest.fn(),
showLoading: jest.fn(),
hideLoading: jest.fn(),
request: jest.fn(),
getStorageSync: jest.fn(),
showShareMenu: jest.fn(),
getSystemInfo: jest.fn(),
setStorageSync: jest.fn(),
uploadFile: jest.fn(),
createSelectorQuery: jest.fn(),
};
// 然后是对页面中setData函数的Mock
export const setData = jest.fn(function fn(newData, cb) {
this.data = {
...this.data,
...newData,
};
if (cb) cb();
});
// 注册组件的Component的Mock,同理可以创建App和Page的。
global.Component = ({ data, properties, ...rest }) => {
const component = {
properties,
data,
setData,
created: noop,
attached: noop,
ready: noop,
moved: noop,
detached: noop,
error: noop,
methods: {},
...rest,
};
global.wxComponentInstance = component;
return component;
}
运行技巧
如果你使用的是vscode,那么在【资源管理器】这个tab下,可以看到NPM脚本的选项,点击我们配置好的test,通过GUI的方式运行脚本。
当然你也可以通过命令行运行来运行测试脚本:
npm run test // 运行所有的测试用例
npm run test targetTastFile //运行单个目标测试文件
写在最后
关于单测能力
这次落地经历,让我们更加深刻的认识到了单元测试的必要性。但是在完成单测落地以后,我从头到尾梳理了一遍以后,还是觉得单测目前在我们项目中的地位很鸡肋,感觉没有非常大的收益。直到同事和我重新说了一下她的想法,我才猛然发现,后补的单测确实很鸡肋,感觉是在徒增开发工作量。但是从TDD(UTDD)视角出发,一切都会变的那么自然而且有力,优先编写单元测试,修改代码也先从单元测试的更改开始,感觉上是一种思维的转变。有兴趣可以看【附录3】的文章(深度解读 - TDD(测试驱动开发)),我觉得是非常好的解答。
就目前了解到的知识来看,我自己还是觉得UTTD可能才会是提升我们编写单元测试意愿的驱动因子。如果总是先写业务再补测试,这种鸡肋的感觉可能会挥之不去。但是鉴于TDD目前还是我们知识的盲区,所以接下来的探索仍然是任重而道远。
文章正确性
第一次整理这种偏记录性质文章,所以文章内容可能存在不得当、甚至是错误的地方,欢迎批评斧正。如果有任何发现或者想法,欢迎随时与我们交流探讨。
内容方面,文章只是基础入门的文章,所以没有详细的对于单元测试划分,及单元测试的进阶能力等方面进行描述及展望,这些仍然需要我们以后一起探索,一起进步。
附录
1.React单元测试策略及落地
作者:ThoughtWorks
链接:https://www.jianshu.com/p/97d8e5e33431
2.jest 文档
作者: FaceBook
链接:https://jestjs.io/docs/zh-Hans/getting-started
作者:SeabornLee
链接:https://www.jianshu.com/p/62f16cd4fef3
写的很不错。我遇到一个问题,mock了wx的方法还是报错方法找不到
我怎么感觉这是我看过最详细的单测文章。
文章写得很好!确实如作者所说,鸡肋感比较强。此外问个问题,这种是否可以输出测试报告
不过还是谢谢鼓励哈!