Vue 应用单元测试的策略与实践 05 - 测试奖杯策略

2019年05月08日

本文的目标

  1. Vue 项目中测试收益如何最大化,如何配置高性价比的测试策略,即什么地方最该花力气测试,什么地方又可以暂且放一放?
// Given
一个具备UT基础但找不到着力点的求索之徒🐒
// When
当他🚶阅读本文的Vue应用测试策略部分
// Then
他能够找到测试的重点,重新燃起对UT的热情🔥
他能够在项目背景下合理配置单元测试的测试策略

单元测试的特点及其位置

前言从敏捷:团队和企业的高响应力谈到单元测试,可能有同学会问,高响应力这个事情我认可,也认可快速开发的同时,质量也很重要。但是,为了达到“保障质量”的目的,不一定得通过测试呀,就算需要测试,也不一定得通过单元测试。

这是一个好的问题。为了达到保障质量这个目标,测试当然只是其中一个方式,稳定的自动化部署、集成流水线、良好的代码架构、甚至于团队架构的必要调整等,都是必须跟上的基础设施。自动化测试不是解决质量问题的银弹,多方共同提升才可能起到效果。

即便我们谈自动化测试,也未必全部都是单元测试。我们对自动化测试套件寄予的厚望是,它能帮我们安全重构已有代码快速回归已有功能保存业务上下文。测试种类多种多样,为什么我们要重点谈单元测试呢?原因很简单,因为它写起来相对最容易、运行速度最快、反馈效果又最直接。

测试奖杯🏆:软件测试的分层策略

测试奖杯(Testing Trophy)是一种自下而上的 Web 应用测试策略。其实这是在说我们需要编写_恰到好处的_测试,给予团队足够的信心 —— 正确的测试,而_不是_仅仅追求达到 100%的测试覆盖率而已。

测试奖杯的四个部分

使用测试奖杯策略,我们可以将这些自动化测试技术进行分层:

  • 使用静态类型系统和 linter 来捕获拼写或语法之类的基本错误。
  • 编写有效单元测试 需要特别针对于应用的某些关键行为或功能。
  • 编写集成测试 以确保 Web 应用各模块之间能够正常协调工作。
  • 创建端到端(e2e)功能测试 对关键路径进行自动化点击操作,而不是等到最终用户来发现问题。

这种四层自动化测试提供了多快好省(放心、快速、省钱)的 JavaScript 专业化测试,最大的特点是它能够反复执行且收益递增,即不需要完全采纳就能获得收益,立马见效。

性价比最高的单元测试

对于一个自动化测试策略,应该包含种类不同、关注点不同的测试,比如关注单元的单元测试、关注集成和契约的集成测试和契约测试、关注业务验收点的端到端测试等。正常来说,我们会受到资源的限制,无法应用所有层级的测试,效果也未必最佳。

因此,我们需要有策略性地根据收益-成本的原则,考虑项目的实际情况和痛点来定制测试策略:比如三方依赖多的项目可以多写些契约测试,业务场景多、复杂或经常回归的场景可以多写些端到端测试,等。但不论如何,整个测试奖杯体系中,你还是应该拥有更多低层次的单元测试,因为它们成本相对最低,运行速度最快(通常是毫秒级别),而对单元的保护价值相对更大。

Vue 应用测试的测试策略

一个常见的 Vue 应用会包括这么几个层面:组件、数据管理、Vuex、副作用等等,对于不同的项目应该有一定的适应性。Vue + Vuex 架构中的不同元素有不同的特点,因此即便是单元测试,我们也会有针对性的测试策略:

架构层级 测试内容 测试策略 解释
action 层 1. 是否获取了正确的参数
2. 是否正确地调用了 API
3. 是否使用了正确的返回值存取回 Vuex 中
4. 业务分支逻辑
5. 异常逻辑
这五个业务点建议 100% 覆盖 这个层级主要包含前述 5 大方面的业务逻辑,进行测试很有重构价值
mutation 层 是否正确完成计算 有逻辑的 mutation 要求 100%覆盖率 这个层级输入输出明确,又包含业务计算,非常适合单元测试
getter 层 是否正确完成计算 有逻辑的 getter 要求 100%覆盖率 这个层级输入输出明确,又包含业务计算,非常适合单元测试
component 层 是否渲染了正确的组件 1. 组件的分支渲染逻辑要求 100%覆盖
2. 交互事件的调用参数一般要求 100%覆盖
3. 被 connect 过的组件不测
这个层级最为复杂,还是以「代价最低,收益最高」为指导原则进行
UI 层 组件是否渲染了正确的样式 1. 纯 UI 不测
2. CSS 不测
这个层级以我目前理解来说测试较难稳定,成本又较高
utils 层 各种辅助工具函数 没有副作用的必须 100% 覆盖  

Component 的测试标准

组件测试其实是前端测试中实践最多,但各方看法最不统一的地方,这也是前后端在谈论单元测试时最大的分歧所在。Vue 组件是一个高度自治的单元,从分类上来看,它大概有这么几类:

  • 展示型业务组件
  • 容器型业务组件
  • 通用 UI 组件
  • 功能型组件

对于 Vue 组件测什么不测什么有一些判断标准:除去功能型组件,其他类型的组件一般是以渲染出一个语法树 render() 为终点的,它描述了页面的 UI 内容、结构、样式和一些逻辑 component(props) => UI。内容、结构和样式,比起测试,直接在页面上调试反馈效果更好。测也不是不行,但都难免有不稳定的成本在;逻辑这块,有一测的价值,但需要控制好依赖。综合上面提到的测试原则进行考虑,我的建议是:两测两不测。

  • 组件分支渲染逻辑必须测
  • 事件调用和参数传递一般要测
  • 连接 vuex 的高阶 SMART 组件不测
  • 渲染出来的 UI 不在单元测试层级测

总结一下,其实每种组件都要测渲染分支事件调用,跟组件类型根本没必然的关联…

组件类型 / 测试内容 分支渲染逻辑 事件调用 @connect 纯 UI
展示型组件
容器型组件
通用 UI 组件
功能型组件

单元测试的 F.I.R.S.T 原则

编写容易维护的单元测试有一些原则,这些原则对于任何语言、任何层级的测试都适用。这些原则不是新东西,但总是需要时时温故知新,前人总结成 F.I.R.S.T 五个原则,以此为镜,可以时时检验你的单元测试是否高效:

  • F Fast:测试需要频繁运行,因此要能快速运行;
  • I Independent:测试应该相互独立,一次只测一条分支;
  • R Repeatable:测试本身不包含逻辑,能在任何环境中重复;
  • S Self-validating:只关注输入输出,不关注内部实现;
  • T Timely:测试应该及时编写,表达力极强,易于阅读;

Fast:运行速度快,频繁运行

单元测试只有在毫秒级别内完成,开发者才会愿意频繁地运行它,将其作为快速反馈的手段也才能成立。那么为了使单元测试更快,我们需要:

  • 尽可能地避免依赖。除了恰当设计好对象,关于避免依赖我已知有两种不同的看法:
    • 使用 mock 适当隔离掉三方的依赖(如数据库、网络、文件等)
    • 避免 mock,换用更快速的数据库、启动轻量级服务器、重点测试文件内容等来迂回
  • 将依赖、集成等耗时、依赖三方返回的地方放到更高层级的测试中,有策略性地去做

Independent:一次只测一条分支

通常来说,一条分支就是一个业务场景,是做任务分解(Tasking)过程的一个细粒度的 task。为什么测试只测一条分支呢?很显然,如此你才能给它一个好的描述,这个测试才能保护这个特定的业务场景,挂了的时候能给你细致到输入输出级别的业务反馈。

常见的反模式是,实现本身就做了太多的事情,不符合单一职责原则(SRP)。如果你发现某个模块的单元测试特别难写的话,那么这个模块的实现本身或输入/输出就足够繁琐,应当作为一种某味道识别出来进行重构。

Repeatable:测试不包含逻辑

跟写声明式的代码一样的道理,测试需要都是简单的声明:准备数据、调用函数、断言,让人一眼就明白这个测试在测什么。如果含有逻辑,你读的时候就要多花时间理解;一旦测试挂掉,你咋知道是实现挂了还是测试本身就挂了呢?特别是对于一些时间或者随机数相关的测试,一定不能够从测试中随机生成这样的测试数据,保证测试中不包含任何过多的逻辑。

但对于一些项目中的 utils 来说,我们期望 util 都是纯函数,即是不依赖外部状态、不改变参数值、不维护内部状态的函数。由于多是数据驱动,一个输入对应一个输出,并且不需要准备任何依赖,这使得它多了一种测试的选择,也即是参数化测试的方式。

参数化测试可以提升数据准备效率,同时依然能保持详细的用例信息、错误提示等优点。jest 从 23 后就内置了对参数化测试的支持,如下:

test.each([
  [['0', '99'], 0.99, '(整数部分为0时也应返回)'],
  [['5', '00'], 5, '(小数部分不足时应该补0)'],
  [['5', '10'], 5.1, '(小数部分不足时应该补0)'],
  [['4', '38'], 4.38, '(小数部分不足时应该补0)'],
  [['4', '99'], 4.994, '(超过默认2位的小数的直接截断,不四舍五入)'],
  [['4', '99'], 4.995, '(超过默认2位的小数的直接截断,不四舍五入)'],
  [['4', '99'], 4.996, '(超过默认2位的小数的直接截断,不四舍五入)'],
  [['-0', '50'], -0.5, '(整数部分为负数时应该保留负号)'],
])(
  'should return %s when number is %s (%s)',
  (expected, input, description) => {
    expect(truncateAndPadTrailingZeros(input)).toEqual(expected)
  }
)

当然,对纯数据驱动的测试,也有一些不同的看法,认为这样可能丢失一些描述业务场景的测试描述。所以这种方式还主要看项目组的接受度。

Self-validating:只关注输入输出,不关注内部实现

比如购物车“计算总价格”这样的一个功能,测试本身不关注内部实现:你可以用reduce实现,也可以自己写for循环实现。只要测试输入没有变,输出就不应该变。这个特性,是测试支撑重构的基础。因为重构指的是,在不改变软件外部可观测行为的基础上,调整软件内部的实现。

另外,还有一些测试实现代码的执行次序。这也是一种“关注内部实现”的测试,这就使得除了输入输出外,还有“执行次序”这个因素可能使测试挂掉。显然,这样的测试也不利于重构的开展。

此外,对外部依赖采取 mock 策略,同样是某种程度上的“关注内部实现”,因为 mock 的失败同样将导致测试的失败,而非真正业务场景的失败。对待 mock 的态度,肖鹏有篇文章Mock的七宗罪对此展开了详细描述,应当谨慎使用。

Timely:表达力极强,易于阅读

测试应该及时编写,只有在当下最熟悉业务的时候,才能够写出表达力最强的测试。而当我们在未来不小心破坏某个功能时,表达力强的测试才能在失败的时候给你非常迅速的反馈。它讲的是两方面:

  • 看到测试时,你就知道它测的业务点是啥
  • 测试挂掉时,能清楚地知道失败的业务场景、期望数据与实际输出的差异

总结起来,这些表达力主要体现在以下的方面:

  • 测试描述。遵循上一条原则(一个单元测试只测一个分支)的情况下,描述通常能写出一个相当详细的业务场景。这为测试的读者提供了极佳的业务上下文
  • 测试数据准备。无关的测试数据(比如对象中的很多无关字段)不应该写出来,应只准备能体现测试业务的最小数据
  • 输出报告。选用断言工具时,应注意除了要提供测试结果,还要能准确提供“期望值”与“实际值”的差异

上述第三点有些测试框架提供了反例,比如说 chai 和 sinon 提供的断言 API 就不如 jest 友好,体现在:

  • expect(array).to.eql(array)出错的时候,只能报告说expect [Array (42)] to equal [Array (42)],具体是哪个数据不匹配,根本没报告
  • expect(sinonStub.calledWith(args)).to.be.true出错的时候,会报告说expect false to be true。废话,我还不知道挂了么,但是那个 stub 究竟被什么参数调用则没有报告

总结一下

“测试需要花费太多时间和精力。”

  • 没时间。 我知道,你已经很忙了。
  • 没有明显的投资回报率。 我知道,你不确定测试到底能带来什么。
  • 没有_办法_测试一切。 我知道,大多数测试都是所谓的_点点点……_。这感觉就像浪费时间,我们都喜欢开发新功能,而不只是对着旧功能“点点点……”。

事实上,没有人有时间。但是,无论如何:

你所开发的软件终将被测试。如果不是由你自己发现,那么就是由你的用户发现(💥Bug)。

「懒惰」是程序员最大的美德

Perl 语言的发明人 Larry Wall 说,好的程序员有 3 种美德: 懒惰、急躁和傲慢(Laziness, Impatience and hubris)。

懒惰:是这样一种品质,它使得你花大力气去避免消耗过多的精力。它敦促你写出节省体力的程序,同时别人也能利用它们。为此你会写出完善的测试或文档,以免别人问你太多问题。

想象一下,将测试软件的繁重工作全部外包给机器。

你是开发工程师呀,这个时代最伟大的脑力工作者啊!你知道人类在处理重复性任务的时候都很糟糕,但是你还知道_机器_非常非常擅长复杂的重复性任务。更专业的开发人员就是会使用计算机来做自动化测试 —— 一整天都在绵绵不休地进行,帮你处理这些测试软件的繁重工作。

  • 自动化测试是专业的。
  • 自动化测试是你的后盾,是你的肌肉。
  • 自动化测试是你的秘密武器……

时不时,问一下自己这几个问题:

  • ,还可以如何偷懒?
  • 应该让计算机帮忙测点什么?
  • 计算机该在什么时候进行测试?
  • 需要 100%的覆盖率吗?
  • 多少次测试就足够了?

未完待续……

## 单元测试基础

  • ### 单元测试与自动化的意义
  • ### 为什么选择 Jest
  • ### Jest 的基本用法
  • ### 该如何测试异步代码?

## Vue 单元测试

  • ### Vue 组件的渲染方式
  • ### Wrapper find() 方法与选择器
  • ### UI 组件交互行为的测试

## Vuex 单元测试

  • ### CQRS 与 Redux-like 架构
  • ### 如何对 Vuex 进行单元测试
  • ### Vue 组件和 Vuex store 的交互

## Vue 应用测试策略

  • ### 单元测试的特点及其位置
  • ### 测试奖杯🏆:软件测试的分层策略
  • ### 单元测试的 F.I.R.S.T 原则

## Vue 单元测试的落地

  • ### 应用测试策略落地的几点建议

本文总阅读量

期待您的分享与讨论: