单元测试有什么好处?
软件进化定律
在软件工程中,软件进化定律是指 Manny Lehman 和 László Bélády 从 1974 年开始制定的关于软件进化的一系列定律。这些定律描述了一种平衡,一方面是推动新发展的力量,另一方面是减缓进展的力量。
Lehman 将软件划分为三类:
- S-program: 根据确切的规范完成某些事。
- P-program: 实现是为了完成某些确定的事,例如一个象棋程序。
- E-program: 执行现实世界的活动,它的行为与其运行环境强相关,这样的程序需要适应环境中的不同要求和情况。
软件进化定律适用于最后一类软件系统。
持续调整定律
“Continuing Change” — an E-type system must be continually adapted or it becomes progressively less satisfactory.
程序需要持续调整(Continuing Change)以满足当前业务需求。但是这个定律经常被忽视。
大多数开发团队在约定日期交付当前软件项目之后,就会转到下一个项目。当前软件如果“幸运”的话,会有另一组人来维护它,虽然另一组人并没有实际编写这个软件。
人们通常关注是否能够找到一个框架帮他们快速发布,而不关注系统以后的发展寿命。
即使你是一个超牛逼的工程师,也无法预见你系统未来需求的受害者。随着业务变化,你编写的出色代码也会变的不再好用。
Lehman 在 70 年代很火,因为他给了我们另一条定律。
复杂度增长定律
“Increasing Complexity” — as an E-type system evolves, its complexity increases unless work is done to maintain or reduce it.
为了满足各种需求,软件会朝着复杂度增长(Increasing Complexity)的方向发展,除非刻意花时间维护去减少复杂度。
这条定律就是说,不能让软件团队作为盲目的“特性工厂”,希望软件长期存活的同时还往软件上堆叠越来越多的特性。
随着我们领域知识的变化,我们必须持续管理软件的复杂度。
重构
所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。 —— 《Refactoring, Martin Fowler》
软件工程在许多方面让软件保持可塑性(malleable),例如:
- 开发者赋能
- 公认的”好“代码,例如合理的关注点分离(SoC )
- 沟通技巧
- 架构
- 可观测性
- 可部署性
- 自动化测试
- 反馈回路(Feedback Loop)
重点关注到重构,这个短语经常被提及,那么这个词从何而来?重构和编写代码的缺别是什么?
许多人和我一样觉得自己在重构代码,但实际上我们可能搞错了。
However the term “refactoring” is often used when it’s not appropriate. If somebody talks about a system being broken for a couple of days while they are refactoring, you can be pretty sure they are not refactoring. —— Martin Fowler
“重构” 这个词经常被不合理的使用。如果某人围绕一个崩溃多天的系统谈论重构,那可以肯定他们不是在重构。
那究竟是什么呢?
因式分解
大家在学校都学过因式分解,这里有个简单的例子:
计算 1/2 + 1/4
为了解决这个问题,需要将分母分解,将表达式变成:
2/4 + 1/4
,然后得出答案 3/4
通过这个例子,我们受到的启发是,如果要将表达式因式分解必定不可改变表达式的含义。
两个表达式都等于 3/4
,我们只是将表达式变得更易理解,将 1/2
变成 2/4
就更容易在我们的“领域”中被适应。
当你要重构你的代码,你实际在努力找到一种方法让你的代码更易理解,并“适应”你当前对系统业务逻辑的理解。至关重要的是你不应该改变代码的行为。
Go 中的例子
这里是一个打招呼的函数,根据指定语言产生不同返回值:
func Hello(name, language string) string {
if language == "es" {
return "Hola, " + name
}
if language == "fr" {
return "Bonjour, " + name
}
// ... 省略更多语言 ...
return "Hello, " + name
}
连续十几个 if
看着是不舒服的,重构如下:
func Hello(name, language string) string {
return fmt.Sprintf(
"%s, %s",
greeting(language),
name,
)
}
var greetings = map[string]string {
"es": "Hola",
"fr": "Bonjour",
//etc..
}
func greeting(language string) string {
greeting, exists := greetings[language]
if exists {
return greeting
}
return "Hello"
}
重构时,可以做任何有意义的事:添加接口、新类型、新函数、方法等等。唯一的准则是不要改变行为。
重构代码不要改变行为
如果你同时改变了程序行为,实际上是在做两件事。作为软件工程师,我们学会将系统分解到不同文件/包/函数/等等,正是因为我们知道要理解一个大整体结构是很困难的。
同时考虑多个事情,就容易犯错。
那么如何确保重构代码的时候,没有改变程序行为呢?
为了安全的重构,你必须要编写单元测试,因为单元测试给开发人员带来了:
- 重塑代码而不必担心改变行为的信心
- 描述系统如何运行的易读文档
- 比手工测试更快、更可靠的反馈
Go 中的例子
为上面的 Hello
函数编写测试用例:
func TestHello(t *testing.T) {
got := Hello(“Chris”, es)
want := "Hola, Chris"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
命令行输入 go test
就能立刻得到结果反馈。实际上,最好记住你常用的编辑器/IDE的快捷键来运行测试。
重构的流程总结如下:
- 一些小的重构
- 运行测试
- 重复以上步骤
在这样一个紧密的反馈闭环中,你就不会掉入陷阱/犯错误。
如果你的项目中,所有关键行为都编写了单元测试。并能在一秒钟内反馈测试结果,那么这个项目足够安全,可以在需要时进行大胆的重构。这就有助于管理软件进化定律中提到的复杂度。
如果单元测试那么好,为什么有人会抗拒
一方面,有人说单元测试对系统的长期健康很重要,因为它确保你可以自信的继续重构。
另一方面,有人说单元测试实际上阻碍了重构。
回想一下,重构需要多少时间修改测试?有许多测试覆盖率很好的项目,如果要重构,修改测试的工作量会很大。这和我们承诺的不一样!
为什么会这样
打个比方,你被要求开发一个正方形,我们觉得最好的实现是将两个三角形拼在一起
我们围绕正方形编写的单元测试,确保每条边相等,然后给三角形写测试:为了确保三角形正确渲染,所以我们要判断内角和为 180 度,还要检查生成了 2 个三角形,等等;测试覆盖率是个重要指标,而且编写这些测试也很容易,所以就写了很多的测试确保行为符合预期。
几周过后,持续调整定律作用于我们的系统,以为新的开发人员做了一些修改。因为现在他认为,用 2 个矩形拼成正方形比 2 个三角形的实现会更好。
他就尝试进行重构,但是先前的测试有许多没法通过。他并没有破坏关键行为,到此时他现在必须要深入研究了解这些三角形的测试才能进行下去。
正方形由三角形组成,实际上不是重点,但是我们的测试错误的提高了这个实现细节的重要级别。
偏好测试行为而不是实现细节
当人们抱怨单元测试是,通常是因为测试处于错误的抽象级别。
这源于对单元测试的误解和追求虚荣指标(测试覆盖率)。
如果只是测试行为,我们不应该只编写系统测试/黑盒测试吗?这类测试在验证关键流程方面有很大作用,但是这类测试通常编写成本高且运行缓慢。 出于这个原因,它们对重构没有太大帮助,因为反馈循环太慢了。而且,相比于单元测试,黑盒测试不能帮你解决根本问题。
那么什么是正确的抽象级别?
编写有效的单元测试是一个设计问题
将单元想象成乐高积木,它们拥有连贯的 API ,我们可以将他们与其他积木组合成更大的系统。
如果您有这些单元遵循这些属性,您可以针对它们的公共 API 编写单元测试。 根据定义,这些测试只能测试有用的行为。 在这些单元下面,我可以根据需要自由地重构实现,并且大部分测试不应该妨碍。
那么单元测试是针对我们描述的“单元”,他们不一定是一个类/功能/其他东西。
将以上概念结合在一起
以上概念涵盖有:
- 重构
- 单元测试
- 单元设计
软件设计的这几个方面是相辅相成的。
重构
- 启发我们编写单元测试。如果我们必须进行手工检查,就需要更多的测试。如果测试无法通过,那么这个测试就处于错误的抽象级别(或者没有价值,应该被删除)。
- 帮助我们处理单元内部和单元之间的复杂度。
单元测试
- 为重构提供安全保障。
- 验证并记录单元的行为。
(精心设计的)单元
- 易于编写有意义的单元测试。
- 易于重构。
是否有一种流程不仅可以帮助我们不断重构代码管理复杂性还能保持系统的可塑性?
测试驱动开发 (TDD)
Test-Driven Development, TDD
有些人可能会接受 Lehman 的关于软件如何改变和过度思考复杂设计的名言,浪费大量时间来尝试创建“完美”的可扩展系统,最终发现这么做是错误的,而一事无成。
在糟糕的软件旧时代,分析师团队花费 6 个月的时间编写需求文档,而架构师团队将花费另外 6 个月的时间进行设计,几年后整个项目还是失败了。
虽然我说的是糟糕的旧时代,但是这种情况现在仍然会发生!
敏捷开发告诉我们,我们需要以迭代的方式工作,从小处着手并改进软件,以便我们能快速获得真实用户的反馈进而设计软件;TDD 强制执行此方法。
TDD 通过鼓励一种不断重构和迭代交付的方法论,来应对 Lehman 的软件进化定律和其他从历史中吸取的教训。
小步快走
- 为一部分预期行为编写一个小测试
- 检查测试失败并显示明显错误(红色状态)
- 编写最少的代码以使测试通过(绿色状态)
- 重构
- 重复以上步骤
随着你能够熟练实践这套流程,这种工作方式会变得自然而快速。
如果你处于系统非“绿色”的状态(因为这表明你可能掉入陷阱),你会期望这个反馈循环不会花费很长时间并且会感到不安。
测试通过的正反馈将驱动你开发小而有用的功能。
收益
加深对需求的理解
先写测试要求我们先整理测试用例,这意味着开发必须将需求消化后才能编写业务代码。不仅开发对需求的理解更深,TDD 还能促进测试、产品以及其他团队角色对需求达成共识。
提高编码效率和程序质量
总所周知,软件开发周期中,Bug 发现越晚,其代价也就越高。使用 TDD 可以将大部分 Bug 扼杀在编码阶段。虽然使用 TDD 的初期开发效率会有下降,但是这点损失相对 Bug 引起的损失可以忽略不计,一旦熟练 TDD 后,编码效率也能得到提升。
有助于程序设计
好的设计都是逐步演进而来的,一开始设计越多越可能 “过度设计” 。使用 TDD ,程序的设计会随着不断重构而演进,可以避免 “过度设计”, 让程序保持松耦合度和可塑性。
有一定的文档价值
开发最讨厌两件事:
- 阅读没有文档的代码
- 为自己的代码写文档
文档是不可或缺的,而同时保持准确性和实时性维护一份文档的代价也很高。某种程度上讲测试作为文档,而这个文档可以随着程序的更新而更新。
总结
- 软件的优势在于我们可以改变它。大多数软件都需要以不可预知的方式随时间变化;但不要尝试过度设计,因为未来很难预测。
- 相反,为了改变软件,我们必须随着它的发展对其进行重构(否则它将变得一团糟),这样我们才能保持我们的软件具有可塑性。
- 一个好的测试套件(test suite)可以帮助你更快、更轻松地重构。
- 编写良好的单元测试是一个设计问题,因此请考虑结构化代码,以便拥有可以像乐高积木一样集成在一起的有意义的单元。
- TDD 可以帮助你以迭代方式设计健壮的软件,并以测试作为保障,以帮助未来的工作更好完成。