从 TDD 到测试策略

by Teobler on 31 / 01 / 2022

views

前端没法 TDD / 前端不容易做 TDD / 前端 TDD 收益不大

这是进公司后无数人给我判的“死刑”。

事实上好像的确如此?

在这个崇尚敏捷的组织里,我们有毕业生的入职前培训,入职后培训,有 TwU,有无数定期不定期的培训。TDD 这个话题贯穿始终,是几乎每一个培训的主战场。

在这个战场上我们的敌人有大兵 FizzBuzz,上尉 MarsRover,王牌 ParkingLot。但是所有的敌人好像都没有长脸,都只是一行行逻辑和命令行输出。敌人的前端好像都是新兵,从未上过战场。我们跟着大家上阵冲杀,披巾斩棘。战胜每一个敌人我心中都有一个疑问:

前端怎么 TDD?

都一样的 / 一个道理。

这是几乎每一次培训我从各个 coach 那里得到的答案,然后每一次我的反应大概都是 - 哦,一样的啊,那没事了。

但每一次上项目实践,我都会眉头一皱,发现事情并没有那么简单 - 好像所有人都忽略了前端各种逻辑和 UI 的耦合,这些耦合会让你的单测变得不那么单元。于是项目上的老人会告诉你:“前端~~只能~~ TDD 纯逻辑。”

是的,就是那些 utils 和极度类似于 utils 的玩意儿。

从 TDD 到测试方法

计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

这是一大段来自 wiki 的解释,可以看出单元测试的目标是最小逻辑单元模块,它要么是一个独立的函数,要么是对象中的一个方法。

从单元测试的角度出发,其实前端做 TDD 的方式豁然开朗 - 找出边界 - 找出这些函数和方法与 UI 的边界。

boundary

以组件为边界

不管是为了测试的合理性还是为了别的什么理由,前端框架的官方们推出了一系列 testing-library,它的思想是通过初始化一个组件的参数,然后将组件渲染到一个环境中,接着在这个环境中模拟用户的页面操作,最后选取一些特定的元素来断言组件的行为是否正确。

之前有提到过前端 TDD 的难点在于 UI 与逻辑的耦合,这种方案索性就不管耦合了。通过模拟用户操作来断言渲染的 UI 是否拥有某个行为/文字/节点,从而达到测试整个组件的目的。

此时的边界将是整个组件。

这样的方案确实解决了没法/不太好 TDD 的痛点,但是带来了一些问题:

  1. 由于要渲染组件,并且要模拟用户行为并等待组件响应,最后才去做断言。比起对于纯逻辑的测试,耗时会增加许多,在项目逐渐变大,测试数量超过 2000 个左右的时候尤为明显,这严重破环了单元测试需要快速反馈的特性;
  2. 说是单元测试,但是这样的测试方法并不“单元”,一个测试里面将所有的东西测了个遍(action / reducer / saga - 如果你有的话),甚至在测试里你需要写一些简单的逻辑去模拟用户事件。最后写出来的往往测试包含无数用户操作和断言,更像是一种集成测试甚至是 E2E 测试;
  3. 需要写选择器来选取特定的元素,这意味着在一些情况下你要为了“单元测试”在产品代码的元素上加上可供选择器选择的属性,比如 test-id / id 等;

以组件逻辑为边界

如果你使用的是三大框架,那么你的 UI 最终是由一系列 template 渲染出来的,而 template 中的数据是从其他地方计算得到的,如果我们能够找到 template 和这个地方的边界,其实我们就已经找到了 UI 和逻辑的边界,如果这时我们将 UI 和逻辑的耦合解开,其实我们就能够测试所有逻辑而不是纯逻辑了,这个时候如果做 TDD 就真的和后端一个道理了。

此时的边界将是组件内的逻辑。

很抽象?没关系,我们来举个例子:

在 React 的函数式组件中,return 语句里面的就是渲染 UI 的 template,所以 return 语句就是一个边界,return 以上为逻辑,return 以下为 template。

根据函数式的思想,任何副作用都应该有明确的标识,那么 - 任何 hooks 都是副作用,所有没有副作用的纯函数都应该以独立的方式存储于某个地方,然后通过 import 的方式导入到当前组件被调用,我们通过组合各种 hooks 和纯函数来计算出 template 所需要的数据,最后把所有的计算逻辑看作这个函数式组件的副作用放入一个 hook 中。

于是我们的组件有了一个清晰的边界 - 渲染组件时传入需要的 props,将 props 传入 hook 中作为输入值计算并导出输出回组件,将拿到的输出给 template 做渲染。

这时候对 hooks 做 TDD 那不是信手拈来?

但是这样的边界划分就没问题了吗?

  1. 这样做的前提是大家都把所有的逻辑统一放到一个地方,但是人总是会想偷懒的,这时候需要团队内认可这样的方式并严格执行 code review 以保证没有人偷懒;
  2. 有的逻辑如果单独从 template 里拎出来会很傻,比如根据某个值来决定是否渲染某个节点或者渲染不同的节点,这样的逻辑放入统一的逻辑中,那么这个函数 / 方法就得返回一个 Node 节点;

问题

有的人可能会说第二种方案遗漏了组件对用户操作的反馈。比如点击按钮时组件的行为。

但我想说的是点击按钮产生的反馈本质上是事件监听和回调,回调最终将会调用某一个函数,第二种方案保证了该函数的行为是正确的。

至于点击了按钮是否真的会调用该回调函数,只要你正确使用了框架语法,那应该是框架的工作,我选择信任框架代码。至于错误使用了语法,首先你的编辑器应该给你报错,其次这样的问题理论上不会频繁出现,再次这种情况不是单元测试应该管的问题。

至于为什么?我想从测试金字塔聊起。

测试金字塔

test-pyramid

测试金字塔提倡我们将所有自动化测试分为不同的粒度,不同的测试应该关注不同的场景。

我见到过许多同学对单元测试的“执念”是想用单元测试覆盖到自己代码中的每一个角落,穷举自己代码中的每一个行为,确保每一行代码都是正确的。与其说这是在写测试不如说是在追求测试覆盖率。

测试应该是关于边界的,边界里面的行为不用关心边界外边的情况,只需要保证自己内部的行为正确即可。那么单元测试就不需要关心你这个单元之外的情况,两个或多个单元之间的合作正常与否应该交给集成测试。

集成测试

回到上一节提到的问题,如果我们将逻辑和 UI 看作两个单元,那么它们之间的集成正确与否(比如按钮的点击是否正确)应该交给集成测试而不是单元测试,这里的集成测试便是组件测试

而之前提到过的官方出品的各种 testing-library 就很适合做这件事情 - 渲染组件,然后模拟用户点击,最后断言这个点击事件被捕获后的组件行为是否正确。

不过话虽如此,但我并不提倡你使用它来做组件测试,我更推荐你用 cypress,在 cypress7.0 中它引入了组件测试的 runner,简单来说它会将组件真的渲染到浏览器中而不是传统的 jsdom based on Node,同时它也帮助开发者规避了许多组件测试的 anti-patterns。

UI 测试

到这里我们已经能够确保各个组件的逻辑是我们所预期的,我们当然也相信这些逻辑计算后通过 template 渲染出来的 UI 也是符合我们预期的。但是我们并没有相应的测试来给我们足够的信心,这时候我们需要 UI 测试来保证我们每一次渲染的页面都是统一的。

这里产生的一个问题是,如果一个页面还处于迭代阶段,那么如果此时我们给这个页面加上 UI 测试,这个测试在频繁的页面更改中就会频繁失败,我们就需要频繁修复这个测试。

如果一个项目对 UI 的正确性要求比较高,为了保证渲染的正确性,我们可以将一个页面拆开,先给已经完成的 section 加上 UI 测试,等整个页面完成后再加上最后的页面测试。如果一个项目对 UI 的正确性要求没那么高,那么只需要等这个页面开发完成再加上 UI 测试即可。

E2E测试

当一个 APP 的某一个 user journey 开发完成后(比如 user login),为了保证其完整性和在之后的开发中不被破坏,我们可以给这个 user journey 加上一个 E2E 测试,模仿用户真正使用这个 APP 时的做法。

需要注意的是,由于 E2E 测试需要联通 APP 的各个部分,是一个比较耗费资源和时间的测试,我们应该尽可能少地使用 E2E 测试,理论上每一个 user journey 只需要一条 happy path 和一条 unhappy path 即可。

写测试是为了什么

test

其实到这里我们自下而上的测试策略都已经有一个大概的样子了,由于篇幅的限制,我没办法在同一篇文章中去详细讨论测试金字塔中的每一类测试。在这里我想表达的思想其实是:

  1. 单元测试不应该包罗万象,请不要过分关注测试覆盖率
  2. 不同粒度的测试应该关注不同的点,它们之间是互补的关系
  3. 在一定条件下前端的 TDD 也可以像后端一样顺滑

最后,测试这件事不应该成为我们的负担,它既不该是一个 KPI,也不该是 QA 份内的事情。它是保证软件质量各个环节中不可或缺的一部分,而比测试更进一步的 TDD 给了我们足够的修改旧代码的勇气,我衷心希望每一位小伙伴都能慢慢意识到这些点并慢慢接受它们。

共勉,祝大家新年快乐~