为重写版本编写单元测试:案例研究
这篇博文是系列文章的第一篇,探讨了我将Raspi IO转换为 TypeScript 并实现架构现代化的过程。本系列文章将深入探讨如何编写专门用于重构或重写项目的单元测试,如何创建可在多个 TypeScript 和非 TypeScript 项目之间共享的 TypeScript 基类和功能,以及如何一次性将现有代码库转换为 TypeScript。
本文最初发表于Azure Medium 出版物。
所有代码库都会随着时间的推移而老化和成熟。随着时间的推移,代码库会变得更加稳定,因此,较老的项目通常也更加可靠。
然而,岁月也会带来种种问题,原有的建筑结构难以满足现代用户的需求。同时,随着时间的推移,开发这些项目的方式也日趋更新、更先进,曾经的前沿技术往往会变得笨拙迟缓。
因此,这些项目面临的问题就变成了:重写还是不重写?我最早的项目Raspi IO也面临着这样的问题,它目前仍在开发中。
Raspi IO 是Johnny-Five Node.js 机器人和物联网框架的一个插件,它使 Johnny-Five 能够在 Raspberry Pi 上运行。我最初在 2014 年将其设计为单体应用,但随着功能的增加,原有架构很快就出现了瓶颈。因此,我在第二年重写了该库,并将其拆分为多个模块。这次重写使得项目能够随着更多功能的添加而扩展。
Raspi IO 目前包含 11 个模块。其中 9 个模块构成了我称之为Raspi.js 的部分,它可以独立于 Raspi IO 和 Johnny-Five 使用。这些模块共同提供了一个完整的 API,用于以统一的方式与 Raspberry Pi 上的硬件进行交互。Raspi IO 和 Raspi IO Core 共同提供了一个从 Raspi.js 到Johnny-Five 的IO 插件规范的转换层。Raspi IO Core 是平台无关的,而 Raspi IO 将 Raspi.js 注入到 Raspi IO Core 中,从而创建一个 Raspberry Pi 专用的 IO 插件。
随着时间的推移,Raspi.js 的所有代码都已转换为 TypeScript 并更新为符合现代编码规范。然而,Raspi IO 和 Raspi IO Core 却几乎三年未曾改动。对于只有 32 行代码的 Raspi IO 来说,这无可厚非,但对于 Raspi IO Core 来说却并非如此。其内部包含 1000 行密集的 JavaScript 代码,充斥着为应对各种奇怪极端情况和 bug 而编写的临时解决方案。这段代码库显然属于典型的“害怕改动会破坏一切”的情况。它也迫切需要更新为 TypeScript 并遵循现代编码规范。
明确了需求后,我坐下来制定了一个计划,要在不影响用户体验的前提下重写 Raspi IO Core。重写的第一步是实现高代码覆盖率的单元测试,因为 Raspi IO Core 由于历史原因没有单元测试(涉及硬件的单元测试非常困难)。
虽然大规模重构和重写能带来诸多优势,例如采用最先进的最佳实践和现代工具,但从用户体验的角度来看,它们本身也存在风险。单元测试就像一道保险,确保重写对用户尽可能透明。
方法论
那么,对于一个没有单元测试且需要重写的项目,该如何实现单元测试呢?答案是:非常系统地,并遵循规范。
如前所述,Raspi IO Core 实现了一个名为IO 插件规范的已发布规范。该规范为模块的行为方式提供了蓝图,实际上也为单元测试本身提供了蓝图。
并非所有项目都实现了 API 规范,但通常情况下,都会有设计文档或其他文档来描述项目的功能。如果没有,那么编写这样的规范就是实现单元测试的第一步。这需要花费大量精力,但我保证它对后续工作大有裨益。除了简化单元测试的实现之外,规范还为所有利益相关者(而不仅仅是程序员)提供了一个平台,让他们可以就项目提出意见并加以改进。如果您不确定从哪里入手,Read the Docs 网站上有很多关于编写高质量规范的优质内容。
接下来要确定的是单元测试技术栈。我决定采用开源 Node.js 模块的通用技术栈,因为我已经很熟悉它们了,而且目前不想学习新的工具或平台:
- Jasmine:一个行为驱动开发(BDD)测试框架。
- Istanbul:一款 JavaScript代码覆盖率工具。代码覆盖率工具可以衡量单元测试执行了多少代码,并提供了一个有用的指标,用于衡量有多少代码被单元测试覆盖。
- Travis CI:一个托管的单元测试平台,可以轻松地在 GitHub 活动(例如提交 PR、推送/合并到 master 分支等)时运行单元测试。虽然重写代码并非绝对必要,但通常建议将单元测试连接到 Travis CI 等托管平台。这样,考虑使用您的库的开发人员无需下载您的代码并自行运行测试,即可查看单元测试结果。
- Coveralls:一个托管的代码覆盖率平台,它与 Travis CI 集成,并提供 Travis CI 的所有价值,只是它提供的是代码覆盖率而不是单元测试本身。
规范和单元测试基础设施都已就绪,是时候编写我的单元测试了!
单元测试流程详解
为了说明如何编写有效的单元测试,我将深入讲解 IO 规范中的一个部分:`get_test_method`digitalRead方法。IO 插件规范对 `get_test_method` 方法有如下描述digitalRead:
digitalRead(pin, handler)
- 启动一个新的数据读取流程
pin - 建议的新数据读取频率大于或等于 200Hz。读取周期可根据平台性能降低至 50Hz,但不能低于 50Hz。
handler对于所有与先前数据不同的新数据读取,调用此函数,并传入一个参数,该参数是从引脚读取的当前值。- 对于所有与先前数据不同的新数据读取,都会创建并发出相应的
digital-read-${pin}事件,该事件只有一个参数,即从引脚读取的当前值(这可以用来调用处理程序)。
我们可以将规范中要求我们做的事情分解成几个需要测试的不同部分,这些部分将构成我们的单元测试集。通读规范后,我确定了以下五个测试:
- 第三点表明我们需要测试通过
handler参数读取值,因为引脚值会随时间变化。 - 第四点表明我们需要测试通过
digital-read-${pin}事件读取值,因为引脚值会随时间变化。 - 第二点表明我们需要测试
handler以 50Hz 或更快的频率调用。 - 第三点和第四点表明我们需要测试该方法是否连续两次报告相同的值。
- 规范的这一部分和其他部分都隐含着这样的意思:我们需要测试
digitalRead即使模式更改为输出模式,程序也能继续读取,并报告通过设置的输出值digitalWrite。
既然我们已经确定了要编写的五个单元测试,下一步就是弄清楚如何编写它们。归根结底,单元测试存在的目的是为了验证在给定相当完整的输入样本的情况下,是否能够生成正确的输出。因此,任何单元测试的第一步都是确定输入和输出。
我们通常认为输入和输出是指传递给函数的参数以及函数返回的值。但这并非全部的输入。例如,如果我们正在测试一个将值保存到数据库的函数,那么除了函数返回的值或它调用的回调函数之外,对数据库的调用本身也是一个输出。在另一种情况下,我们会调用其他与硬件交互的模块(这又增加了输入和输出!)。一般来说,存在两组或多组输入输出digitalRead是很常见的。
单元测试的关键在于如何测量下图“后端”的输入和输出。通常,这是通过模拟(mocking)来实现的,这也是我在这里选择的解决方案。Raspi IO Core 的架构使得这项工作非常简单,因为我们可以传入Raspi.js 中所有模块的模拟版本。我们正在测试的完整输入和输出如下所示:
这些模拟版本包含硬件的虚拟实现,并将输入/输出暴露给该模块,以便我们可以在单元测试中对其进行验证。对于此单元测试,我们使用DigitalInput以下代码的模拟版本:
class DigitalInput extends Peripheral {
constructor(...args) {
super([ 0 ]);
this.value = OFF;
this.args = args;
}
read() {
return this.value;
}
setMockedValue(value) {
this.value = value;
}
}
我们添加了一个setMockedValue在真正的 Raspi GPIO 类中不存在的额外方法DigitalInput。这使我们能够精确控制 Raspi IO Core 将读取哪些数据。我们还添加了一个新的属性args,可以用来查看传递给类构造函数的参数。有了这些,我们就可以测量被测黑盒“后端”的所有输入和输出。
现在到了编写单元测试的时候了。我们将来看一个使用回调函数读取值的单元测试:
it('can read from a pin using the `digitalRead` method',
(done) => createInstance((raspi) =>
{
const pin = raspi.normalize(pinAlias);
raspi.pinMode(pinAlias, raspi.MODES.INPUT);
const { peripheral } = raspi.getInternalPinInstances()[pin];
let numReadsRemaining = NUM_DIGITAL_READS;
let value = 0;
peripheral.setMockedValue(value);
raspi.digitalRead(pinAlias, (newValue) => {
expect(value).toEqual(newValue);
if (!(--numReadsRemaining)) {
done();
return;
}
value = value === 1 ? 0 : 1;
peripheral.setMockedValue(value);
});
}));
我们首先编写一些初始化代码,使测试引脚准备就绪,可以进行读取。然后,我们调用 `get_test_pin()` getInternalPinInstances,这是一个特殊的钩子方法,仅在运行单元测试时才会暴露。该方法返回 `test_pin()` 的模拟实例,以便我们可以访问上面讨论过的DigitalInput钩子。DigitalInput
然后,我们设置了一些状态监控变量。由于此方法需要持续读取数据,我们必须测试它是否可以多次读取。numReadsRemaining该变量跟踪已执行的读取次数和剩余的读取次数。由于如果该值不变,则不会调用回调函数,因此我们会在每次回调中切换该值。在每次回调中,我们都会测试 Raspi IO Core 报告的值是否与我们在模拟类中设置的值相同DigitalInput。
至此,单元测试完成!如果您想查看构成DigitalInput测试的所有单元测试,可以在 GitHub 上找到它们。
经验教训
在这个过程中,我学到了关于单元测试和重写的几个重要经验教训。
特殊情况比常见情况更重要。
我们对常见用例进行了大量的测试,并且编写代码时也充分考虑了这些常见用例。而边缘用例通常需要通过反复试验或用户反馈才能发现。因此,在重写现有代码库时,我们需要确保将这些边缘用例也移植过来,因为它们在“一开始”就被修复的可能性要小得多。编写单元测试来测试这些边缘用例是确保它们被纳入重写代码库的最有效方法。
务必具体明确,切勿笼统概括。
编写单元测试时,很容易快速编写一些大致能测试我们想要测试的内容的测试用例。例如,如果我们要测试一个函数在接收到错误参数时是否会抛出异常,我们可以这样写:
expect(() => {
add(NaN, `I'm not a number`);
}.toThrow();
这个测试确实会通过,但我们如何确定它通过是因为该add方法正确检测到了我们试图将两个非数字相加呢?如果代码中存在一个真正的 bug,恰好在相同的输入下抛出了异常呢?我们应该这样编写这个测试:
expect(() => {
add(NaN, `I'm not a number`);
}.toThrow(new Error(`non-numbers passed as arguments to "add"`);
这样,我们就能确保它按预期抛出错误。如果我们不直接复制粘贴错误信息,还能避免拼写错误。这看似无关紧要,但有时用户的代码依赖于错误信息的内容,因为他们需要根据抛出的错误做出相应的决策。如果我们更改错误信息,就会破坏这段代码。想要深入了解错误信息为何重要(以及为何棘手),我建议阅读 Node.js 项目本身是如何改进其错误处理方式的。
良好的代码覆盖率对于代码重写比对于日常开发更为重要。
理想情况下,我们都能实现 100% 的代码覆盖率。然而,在实践中,100% 的代码覆盖率很少是理想的,有时甚至是不可能的。事实上,Raspi IO Core 的覆盖率只有 93%,因为大部分未测试的代码都是无用代码。这些无用代码大部分是 Babel 自身引入的运行时代码,而 Babel 的版本确实已经过时了。其余的代码是我当初认为必要的,但实际上很可能也是无用代码。此外,还有一些代码与测试期间不存在的某些东西(例如外部传感器)紧密相关,如果对所有必要的组件进行模拟,最终得到的单元测试实际上只是在测试模拟对象,而不是代码本身。
虽然代码覆盖率不可能达到 100%,但对于重写项目而言,高代码覆盖率比日常编码更为重要。这是因为统计学原理决定的。在重写过程中,我们会修改大量的代码,这些修改最终会被大量的单元测试覆盖,从而覆盖大量的边界情况。而日常编码很少会涉及如此广泛的改动。因此,重写过程中出现回归的概率更高。高代码覆盖率是预防回归的最有效方法,因此,当我们处理像重写这样回归风险较高的变更时,高代码覆盖率就显得尤为重要。
针对规范编写单元测试也能改进规范。
尽管我们都希望规范完美无缺,但它们毕竟是由人编写的。就像编写代码的人一样,编写规范的人有时也会犯错,并在规范中引入缺陷。针对规范编写单元测试通常可以突出显示规范中存在歧义或错误的地方。在为 Raspi IO Core 编写单元测试时,我发现了规范中的多个问题。其中三个问题是 我们忘记将一些新增功能添加到规范中。另外两个问题是规范本身存在歧义。编写单元测试的过程可以出人意料地有效地帮助我们发现规范中的问题。
结论
我之前尝试过四五次将 Raspi IO Core 转换为 TypeScript。每次尝试都失败了,因为我很快就怀疑能否为用户提供顺畅的升级路径。没有单元测试,我对自己的改动没有信心。编写单元测试是之前几次尝试失败的关键所在,现在我准备继续推进 Raspi IO Core 的 TypeScript 转换,并在转换过程中重构其主要架构。
这项工作真正重申了单元测试的重要性,以及理解我们测试什么、如何测试以及为什么测试的重要性。
文章来源:https://dev.to/azure/writing-unit-tests-for-a-rewrite-a-casestudy-466m

