【CICD】引入单元测试
在上一节,我们主要讲了如何通过 Github Action 来做一些简单的 lint 校验和代码格式化检查,这一节我们来学习一下如何写编写单元测试以及如何把单元测试作为我们 CI 的一部分来确保我们的整体的代码质量。
什么是单元测试
在学习如何写单元测试之前,我们首先要了解一下什么是单元测试。
在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试 ,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。 通常来说,程式设计师每修改一次程式就会进行最少一次单元测试,在编写程式的过程中前后很可能要进行多次单元测试,以证实程式达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是必须的,但也不坏,这牵涉到专案管理的政策决定。 每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用 stubs、mock 或 fake 等测试马甲程序。单元测试通常由软件开发人员编写,用于确保他们所写的代码符合软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。
理想情况是开发同学每次编辑/创建一个功能,都应该加上相应的测试用例, 但是现实的情况会更加复杂一点特别特别的,特别是对于前端环境来说,因为前端的同学面临着你需要去频繁的去更新项目的界面UI,而这些内容是很难被以代码的形式测试的,就算能够以代码的方式去测试到,但是他的维护成本也会非常高,而相对的他的收益又是相对模糊的,导致很多商业的项目在自动化测试用例上的投入并不会很高, 因为对于大部分的商业项目来说,自动化测试用例的投入与维护的成本对比模糊的产出比不上招聘一个QA同学来专门负责测试会收益更加清晰。相对的,良好的开源项目会更加依赖于单元测试而不是人工测试。因为需求变化少,人力成本高,以及缺少定期的回归测试。这两方面的差异导致的不同的项目对于自动化用例的看法也会有一些偏差。
当然,收益的模糊意味着我们不需要去编写任何的单元测试用例了吗?答案是否定的。 对于前端能做到的UI测试,我们现在学的投入会非常高产出的话会比较模糊,但是对于纯函数编写测试用例来说,则是一种投入低以及产出稳定的选择。所以我一般都会推荐我的团队成员去编写一些纯函数,特别是功能复杂的纯函数的单元测试,如果一些复杂的操作被耦合在一些上下文中,那么我也会推荐看能不能把他抽象成一个独立的函数,这样也可以做好代码的抽象工作,方便后续的同学持续性的维护代码。
同时,在编写纯函数的测试用例上践行TDD(Test-Driven Development, 测试驱动开发, 指先编写测试用例和单元测试,然后编码实现其功能。)也会变得可行,因为纯函数的输入输出都是明确的,没有任何副作用。
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
- 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
设计单元测试
我们举一个简单的例子,我们有一个函数 sum ,它接受两个参数: a 和 b ,然后输出 a+b 的结果。那么这个 sum 函数是一个纯函数么?是的,因为它的输出永远取决于它的输入,而与它当前执行时所处的环境是无关的。
那么现在我们要开始为这个sum函数编写测试用例了。
首先我们设计一下正常用法,比如 1 + 2 = 3。
然后,输入有没有可能是负数或者0?比如-1 + 0 = -1.
那么除了复数有没有可能输入字符串?比如 "1" + 2 = 3, "一" + 2 = 2 ?
既然可能输入字符串了,有没有可能输出对象?输入undefined?输入null? 比如 {"foo": "bar"} + 2 = 2, null + 2 = 2, null + undefined = 0?
我想我已经想到了大部分的正常情况与非正常情况,那么借此我们可以得到一个真值表:
好的,那么我们设计完了真值表以后我们就要开始写代码了,在这里我们会去使用一个目前业界主流的单元测试库 jest (https://jestjs.io/),当然不管是什么样的单元测试库,其内核本质上就是去拿不同的输入去执行固定的指令,最后判断结果是否符合预期,所以不需要拘泥于选用什么形式,而是要看到其内核本质。
编写单元测试
好的,那么在我们定义好一个单元测试需要做什么事之后,那么我们就需要开始编写单元测试了,当然在此之前,我们还要对jest做一些简单的配置:
安装Jest
初始化Jest
根据交互式程序一步步创建即可创建一个最基本的配置文件。运行也非常简单,只需要在命令行中输入npx jest 即可,当然为了方便一般会建议加入到package.json 中(按照最新版的jest --init 会询问你是否要加入,如果选否可以后续手动加入)。
执行 npm run test 自动查找匹配规则的单元测试文件。
编写单元测试
官方文档上有个简单的示例,创建sum.test.js 文件, 内容如下:
这是一个很简单的测试用例,即简单的输入固定参数然后检查输出的返回值,在本例中定义了测试名称,然后执行 sum 函数,输入参数(1, 2) 然后通过toBe 指令来验证结果是否为3 。
我们可以基于此来做一些简单的改造,使其能够适配我们所能想到的用例并且更加方便的拓展更多的测试用例。这里我们要用到jest 的test.each 方法。
根据上面的真值表,我们可以得到以下的测试用例:
我们把通用的逻辑抽象出来,然后把单元测试用例单独存放,通过 test.each 的数组形式来将测试用例一个传入到测试脚本中,用 (a, b, output) 分别接受第一、第二、第三个参数,然后通过回调函数的形式将参数传入给测试脚本,最终实现用例和脚本隔离。
虽然到此为止,我们的 sum 函数一行代码也没有写,但是我们已经能够通过单元测试了解这个函数是干什么的了,以及他对于一些边界情况的返回是如何处理的。这也是单元测试的另一个好处,我们无需了解一个函数内部是怎么实现的,只需要关心这个函数的输入输出即可。
虽然 sum 函数内部逻辑比较简单,为了确保大家能够简单的重复本节内容的结果,我这边也简单的写一下。当然建议大家可以不看示例代码自己手动尝试一下先写单元测试再写函数内容,通过单元测试的不断反馈不断修正代码逻辑。 sum.js:
集成到CI流程中
好了,我们在前面的内容中学习了什么是单元测试以及如何编写单元测试,现在我们要将单元测试集成到我们的CI流程中,确保每次迭代都会自动进行单元测试,来保证整体迭代质量。
实现方式也非常简单,当然,我们可以把单元测试的流程加载git hook 中,但是随着项目的不断扩大,执行单元测试的时间也会变得非常漫长,有的历史比较久远的项目可以执行几个小时甚至几天。因此我是非常不建议把单元测试放在git hooks阶段中的,因为单元测试和代码静态检查有本质的区别 —— 单元测试需要上下文,即整个项目的运行环境,而静态检查只需要单个文件即可,因此静态检查可以做到只扫描修改的文件,而单元测试则是每次都是全量执行。这就是为什么我会推荐大家把代码静态检查放在本地(提前发现问题)而把单元测试放在远程(并行操作)。
所以, 为了整体的研发效率考量,我个人比较推荐把单元测试的流程放在远程的CI pipeline中。还是以github action 为例,只需要在上一节的.github/workflows/ci.yaml 中增加一行配置即可:
是的,因为我们之前的准备工作做的非常充分,因此只需要很简单的一步就能将单元测试流程集成到远程。只需要提交代码就能看见我们的单元测试会在每次提交都会自动执行了。
总结
在本节,我们学习了什么是单元测试、如何设计的单元测试以及如何用代码编写测试,最后将单元测试集成到CI流程中,来确保项目整体质量。在下一节,我们会学习如何实现自动发布(CD),让我们每次提交代码自动部署项目。
小思考: 你写的代码中有哪些是纯函数呢?这些纯函数如果写成单元测试可以有多少情况呢?
