评论

内部团队单元测试探索、落地及后续思考

站在巨人的肩膀上,搞搞基于jest的单元测试在小程序中的落地,欢迎大家多多指教。

说明

本文记录了内部团队为小程序探索、搭建基于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,
        },
      });
    },


仿照规则,编写测试

首先,依照目标,我想要测试推荐页的发布函数的代码(代码如上)。


  1. give:引入发视频函数。
  2. when:调用发视频函数
  3. 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 逻辑,我们可以很明白的整理出:

  1. this.handlePrintStatistic 将会被调用。
  2. 当前页面的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

3.深度解读 - TDD(测试驱动开发)

作者:SeabornLee

链接:https://www.jianshu.com/p/62f16cd4fef3


最后一次编辑于  2020-03-20  
点赞 5
收藏
评论

4 个评论

  • 测试
    测试
    2021-08-06

    写的很不错。我遇到一个问题,mock了wx的方法还是报错方法找不到

    2021-08-06
    赞同
    回复 1
    • dzk
      dzk
      2021-09-13
      不做小程序开发好久了,可能不能给你提供帮助了,抱歉。
      2021-09-13
      回复
  • 卡尔
    卡尔
    2021-01-21

    我怎么感觉这是我看过最详细的单测文章。

    2021-01-21
    赞同
    回复
  • Vic陈焱林
    Vic陈焱林
    2020-07-18

    文章写得很好!确实如作者所说,鸡肋感比较强。此外问个问题,这种是否可以输出测试报告

    2020-07-18
    赞同
    回复 1
    • dzk
      dzk
      2020-07-22
      可以, 运行 jest 命令时加参数就行。
      2020-07-22
      回复
  • 小肥羊🍊
    小肥羊🍊
    2020-03-22
    虽然我看不懂,但不否认写的非常好
    2020-03-22
    赞同
    回复 1
    • dzk
      dzk
      2020-03-22
      不,如果读的人没看懂,说明写的还不够。
      不过还是谢谢鼓励哈!
      2020-03-22
      回复
登录 后发表内容