前言
工作一段时间,你或多或少写过一些单元测试,至少听过单元测试的概念。
最近跟团队的同学交流单元测试编写经验的时候,发现大家基本缺乏对于单元测试理论知识的系统学习,很自然地在写单测的过程中产生了以下一些疑问:
- 怎么去评判一个单元测试的用例是否有效可靠?
- 写单元测试到底有什么意义?
- 单元测试在所有的测试类型中定位是什么?
写代码虽然是一个重实践的手艺,但是大多数人忽视了理论知识所带来的影响。在软件开发领域中,无论是软件工程,还是 OOP 编程中的设计模式,这些理论知识使得我们更高效、使用最佳地姿势进行软件开发。
基于这样的背景,我将从以下的几个角度系统地介绍单元测试相关的知识:
- 单元测试的定义
- 单元测试的意义
- 单元测试的适用场景
- 从前端视角出发去拆分单元
- 写好一个用例的原则
- 集成测试
- 测试驱动开发(Test-Driven Development)
定义
《单元测试艺术》对单测的定义如下:一个单元测试是一段代码,这段代码调用一个工作单元,并检验该工作单元的一个具体的结果。如果关于这个结果的最终假设是错误的,单元测试就失败了。一个单元测试的范围可以小到一个方法,大多多个类。
这里的工作单元指的是:我们调用软件中的一个方法,这个方法执行过程中所发生的所有行为以及最后产生的结果的总和。
这个定义已经基本接近我心中的标准答案了,对工作单元的定义代表了单元测试的精髓:方法执行过程中所发生的所有行为以及最后产生的结果的总和, 这半句也为我们在写单测的用例时是基于行为还是状态去断言提供了理论基础。
基于状态的测试一般是利用对象内部状态来验证执行结果的正确性,我们需要获取待测对象的状态,然后与期望的状态做对比,进行验证。通俗地讲就是断言的是输出或者对象的状态变化。
基于交互的测试则是验证待测对象与其协作对象以我们期望的方式进行交互,而非验证这些对象的最终状态是否匹配。通俗地讲就是断言的是 mock 函数的执行情况。
使用哪种方式,取决于你的测试场景,没有孰优孰劣。
对于单元怎么去划分,可能不同的场景会有不同的拆分方式,甚至可以根据个人的理解去拆分单元,但是万变不离其宗。关于这一点,我会在后面的内容中单独介绍。
意义
大多数同学可能是完成 OKR 式或者机械地去写一些单元测试,缺少对单元测试所带来的意义地思考。
我个人的理解是,有些场景并不是引入了单元测试或者有单元测试就能证明代码的质量好,代码测试越多越好是一个伪命题。很多人会忽视引入代码测试以及无效的测试所带来的维护成本,例如增加了开发的耗时,或者拖慢了 CI、CD 流程。
假设不考虑时间成本,那么还是鼓励大家为代码编写有效可靠地单元测试。单元测试有如下意义:
- 通过用例确保模块的功能,不至于在迭代过程中产生 bug
- 重构模块时,因为有单元测试的覆盖,也能放心大胆地去做(搞坏了,换 Rust 😁)
- 时间长了以后,如果模块逻辑越来越复杂,通过单测用例,也能比较快地了解模块的功能
- 提高代码质量,通过单元测试发现代码中冗余的逻辑,或者为了可测,使得代码设计的与外部模块更加解耦
场景
2009 年 Mike Cohn 在《Succeeding with Agile: Software Development using Scrum 》(《Scrum敏捷软件开发》)一书中提出了测试金字塔的模型:
这种金字塔结构,反映地是作者建议在各层自动化测试的投入时间分配比例,底层的单元测试投入最多,界面层的投入最少。
Mike Cohn 是 Mountain Goat Software 公司的创始人,该公司提供 Scrum 和敏捷软件开发的培训与咨询。他擅长帮助企业实施 Scrum,使其更敏捷,从而成长为高效的软件开发组织。他的作品包括《Scrum 敏捷软件开发》、《用户故事与敏捷方法》、《敏捷估算与规划》以及几本 Java 和 C++ 编程相关图书。
但是现实中的项目演变结果却是冰淇淋模型:
大多数的团队人员因为业务需求压力、时间成本的问题几乎不可能刚开始就让项目的代码测试向标准的金字塔模型对齐,随着业务的迭代,当我们有时间回过来头想补充一些单元测试,很多时候发现,大多数代码已经 not testable(不可测) 。
所以,有限的资源、有限的时间,我们必须做选择。下面,我根据个人的一些理解,列出一些单元测试适合的场景和不适合的场景(前端视角)。
适合的场景:
- 基础 SDK,例如我们交易团队的支付 SDK
- 组件库,组件的输入(props)和输出(render 结果)稳定,可测性很强
- 模式库(utils),大多数 utils 有输入输出,是单元测试非常易测的场景
- React 自定义 hooks,hooks 本质是一个函数,当然可能是一个有状态或者副作用的函数,结合 react-test-library 提供的方法也是易测的
- ...
可能没法穷举,但是我们可以发现它们会有一些共同点:
- 有稳定的输入输出的模块
- 组件或者 React Hooks,得益官方或社区提供的一些辅助测试方法
不适合的场景:
- 与 UI 强相关、迭代频繁的功能,例如 UI 相关的功能
- 脚手架工具(可能有争议,纯个人理解)
- ...
其实不适合的场景特点如下:
- 投入成本过高,收益不大
例如脚手架工具,其中的功能模块大多涉及文件的读写,通过单测覆盖,要么需要去 mock 文件的读写方法或者为了不产生无用的测试产物去引入内存文件系统(写着写着我就哭了 😭)。笔者在亲自参与过内部的 cli 工具的单测编写后,果断弃坑,采用集成测试覆盖。
前端眼中的单元
如果从单元测试所应用的模块来看,前端的视角可以是如下划分:
- 一个 util,一般是一个有输入输出的函数
- 一个 component,一般是有多个可以输入的 props,然后输出具有不同状态的对象(所以可以通过快照方式进行断言)
- 一个 hook,一个具有副作用的函数或者一个有输入、有状态值输出的函数
- 一个 class,一个具有多个状态或者方法的可被实例化的对象
- ...
尽可能地枚举,但是还是没办法穷举。
根据以上的场景,怎么去拆分如前文提到的工作单元?
一个 util,非常好理解,那就是 util 本身的调用,如果有多个参数,根据不同的参数组合产出不同的用例。
一个 component,本身输入的是 props,那么直接根据不同的 props 传入就得到了不同的工作单元,当然需要留意可能不同的 props 之间的组合也会产生不同的工作单元。
一个 hook,本质跟函数的测试没有太大的差异,只是可能最后断言或者执行 hook 的方式有差异。
一个 class,实例对象的一个 public 方法调用或者一个实例属性的修改可以作为一个工作单元。
怎么写好一个用例
常有开发同学有这样的疑问:衡量一个好的用例有哪些标准?我写的用例是否有效可靠?
根据业界的一些原则以及我个人的经验,衡量一个好的测试用例需要遵循如下原则:
- 自动的(Automatic)
所有的测试用例应该能够自动运行,不需要通过交互地进行,包括在 CI 中集成测试的流程。
- 独立的( Independent )
各个测试用例之间应该没有任何依赖关系,测试用例之间随意调换执行顺序,始终不影响测试结果。
- 可重复执行的( Repeatable )
测试用例可以在 CI 中或者任意时间重复执行,在代码没有变动的情况下,每次测试的结果都是稳定的。
- 快速的(Fast)
单元测试运行速度是快速的,就算是大型项目,运行的时间应该是分钟级别。
- 可自我验证的(Self Validating)
单元测试应该是可以通过简单的命令做到自执行,不需要人工验证。
- 及时的(Timely)
在新增了 feature 和修复了问题后,测试用例应该及时更新。对于无效的测试,应该及时清理。测试代码应该看做源码的一部分,也需要及时重构和更新。
- 隔离的( Isolated )
测试用例之间应该做到互相不干扰,全局状态的修改或者 DOM 树的调整,如有必要,需要做一些状态重置。
- 可读的(Readable)
好的测试用例应该是可读的,测试用例的描述应该做到简明扼要。单个用例代码应该避免出现大量的代码和逻辑,例如出现 if、for等语句。
- 易维护的(Maintainable)
测试代码也是代码,所以可维护性也很重要。将一些通用的测试初始化逻辑,通过函数进行封装,一定程度上提高测试代码的可维护性。
结合前面的原则,写出有效可靠的测试还需要做到:
- 删除和修改过时的测试
过时的测试比没有测试更可怕,只要有测试代码存在,就会产生在一定的维护成本,而且还可能产生一定的理解成本。
- 测试用例中避免过多的逻辑
测试用例中出现大量的逻辑,也就意味着测试用例的可读性和可维护性急剧下降,甚至测试代码本身出现 bug 的几率上升。
- 每个测试只有一个关注点
如果一个测试中包含多个关注点,那么你的测试用例描述也会变得复杂。而且大多数框架,在第一个断言失败后就不会执行后面的断言,所以如果你有多个关注点,应该分开不同的测试用例。
- 将单元测试和集成测试分开
为了保证单元测试执行的速度,不要把单元测试和集成测试混在一起。混在一起,也会使得你的测试执行成本更高,结果变得不稳定。
有了以上的一些原则指导,你将朝着写出有效可靠的测试的目标又近了一步。当然,写出好的测试用例不是一蹴而就的事,还是需要在理论基础的支撑下,不断动手实践。
集成测试
任何代码测试,如果它运行速度不快,结果不稳定,或者依赖被测试单元的一个或者多个依赖物(可能是一个服务,也可能是一个模块),这样的测试,一般称之为集成测试。
集成测试会使用真实依赖物,单元测试则把被测试单元与其依赖物隔离开,以保证单元测试结果高度稳定,能轻易控制和模拟被测试单元行为的任何方面。
测试驱动开发(Test-Driven Development)
TDD 解决的是何时编写单元测试的问题。
传统编码方式流程如下:
基于 TDD 的开发流程如下:
TDD 首先会编写一个失败的测试,然后编写代码,并确保这个测试通过,最后重构代码或者创建另一个失败的测试。
TDD 优点:
- 提高代码质量,提前优化代码设计
- 提升开发者对代码的信心
- 缩短发现缺陷的时间,减少缺陷的数量
缺点:
- 时间成本高,可能导致项目延期
- 使用不当,也可能降低代码的质量
总结
本文从单元测试的定义开始,到揭示单元测试对于项目的意义以及适用场景,总结了写好测试用例的一些原则,最后也引入了 TDD 测试的介绍,从中我们了解到:
- 单元测试作为金字塔模型最底层的测试类型,值得投入更多的时间进行编写
- 对于业务团队,需要考虑投入产出比,只针对核心的 SDK 或者易测的模块进行测试
- 写出一个可靠有效的测试,需要遵循若干原则,例如自动的、独立的、快速的、可读的等。理论很重要,但是只有多动手,才能写出可靠有效的测试
- TDD 虽然是一种非常好的开发方式,运用得当,能极大提升代码质量。但是考虑到时间成本,在大多数业务开发场景难以执行