我们的代码太复杂,无法测试
专业开发者经常需要处理高度复杂的代码,这是不争的事实。这也引出了一个合理的反对意见:我们的代码太复杂,难以编写单元测试。我们处理的代码可能是非常有价值、值得信赖的遗留代码,为公司带来了显著的收入。这些代码可能很复杂,但它是否复杂到无法测试?是否可以说每一段复杂代码都无法测试?
理解不可测试代码的原因
答案在于代码变得复杂且难以测试的三种方式:
-
意外复杂性:我们无意中选择了一种复杂的方式,而不是更简单的方式。
-
外部系统无法控制以支持测试。
-
代码过于纠缠,以至于我们无法理解它。
意外复杂性 使代码难以阅读和测试。理解这一点最好的方式是认识到任何问题都有许多有效的解决方案。例如,假设我们需要将五个数字相加。我们可以写一个循环,也可以创建五个并发任务,每个任务处理一个数字,然后将结果报告给另一个计算总数的并发任务(请耐心听我说……我确实见过这种情况)。我们还可以设计一个基于复杂设计模式的系统,让每个数字触发一个观察者,将其放入一个集合中,再触发另一个观察者来累加总数,并在最后一次输入后每 10 秒触发一次观察者。
是的,我知道其中一些方法很荒谬。这些都是我编的。但说实话,你以前遇到过哪些荒谬的设计?我知道我曾经写过比实际需求更复杂的代码。这个五个数字相加的例子关键在于,它本应该使用一个简单的循环。其他任何方法都是意外复杂性,既非必要也非有意。我们为什么会这样做?原因可能有很多,比如项目限制、管理指令,或者仅仅是个人偏好。无论如何,更简单的解决方案是存在的,但我们没有选择它。
测试更复杂的解决方案通常需要更复杂的测试。有时,团队认为不值得花时间在这上面。代码很复杂,编写测试会很困难,而且我们认为它已经可以工作了。我们觉得最好不要碰它。
外部系统 会给测试带来问题。假设我们的代码与第三方网络服务通信,编写可重复的测试会很困难。我们的代码消费外部服务,而每次发送给我们的数据都不同。我们无法编写测试来验证服务发送的内容,因为我们不知道服务应该发送什么。如果我们可以用一个我们可以控制的虚拟服务替换外部服务,那么这个问题就很容易解决。但如果我们的代码不允许这样做,那我们就束手无策了。
代码纠缠 是这一问题的进一步发展。要编写测试,我们需要理解代码对输入条件的处理方式:我们期望输出是什么?如果我们有一段完全无法理解的代码,那么我们就无法为其编写测试。
虽然这三个问题是真实存在的,但它们都有一个根本原因:我们允许软件陷入了这种状态。我们本可以安排它只使用简单的算法和数据结构。我们本可以隔离外部系统,以便在不依赖它们的情况下测试其余代码。我们本可以将代码模块化,使其不至于过于纠缠。
然而,我们如何用这些观点说服团队呢?
重新审视良好设计与简单测试之间的关系
前面提到的所有问题都与开发出能够运行但未遵循良好设计实践的软件有关。根据我的经验,改变这一现状最有效的方法是 结对编程——即两个人一起编写同一段代码,互相帮助找到更好的设计思路。如果结对编程不可行,那么 代码审查 也可以作为一个引入更好设计的检查点。结对编程更好,因为等到代码审查时,可能已经来不及进行重大修改了。预防糟糕设计比纠正它更经济、更高效、更快速。
如何管理没有测试的遗留代码
我们会遇到没有测试的遗留代码,而这些代码需要维护。通常,这些代码已经变得难以管理,理想情况下需要替换,但问题是没有人再知道它的具体功能了。可能没有书面文档或规范来帮助我们理解它,即使有,也可能已经完全过时且毫无帮助。代码的原始作者可能已经转到其他团队或其他公司了。
在这种情况下,最好的建议是尽可能不要动这段代码。但有时,我们需要添加功能,这就要求我们修改这段代码。由于没有现有的测试,我们很可能会发现添加新测试几乎是不可能的。代码的结构没有为我们提供挂载测试的接入点。
在这种情况下,我们可以使用 特征测试(Characterization Test) 技术。我们可以将其描述为三个步骤:
-
运行遗留代码,为其提供所有可能的输入组合。
-
记录每次输入运行后的所有输出。这些输出传统上被称为 “黄金标准(Golden Master)”。
-
编写特征测试,再次使用所有输入运行代码,并将每个输出与捕获的黄金标准进行比较。如果任何输出不同,则测试失败。
这种自动化测试将我们对代码所做的任何更改与原始代码的行为进行比较。这将指导我们重构遗留代码。我们可以将标准的重构技术与 TDD 结合使用。通过在黄金标准中保留有缺陷的输出,我们确保在这一步骤中纯粹进行重构,避免同时进行代码重构和修复错误的陷阱。当原始代码中存在错误时,我们分两个阶段进行工作:首先,在不改变可观察行为的情况下重构代码;然后,将修复缺陷作为单独的任务进行。我们从不将修复错误和重构混在一起。特征测试确保我们不会意外地将这两项任务混淆。
我们已经看到 TDD 如何帮助解决意外复杂性和修改遗留代码的困难。但是,在编写生产代码之前编写测试是否意味着我们需要在测试之前知道代码的样子?接下来,让我们探讨这一常见的反对意见。