为什么使用基于属性的测试?
请了解一下基于属性的测试及其相较于传统测试技术的优势。
测试
测试可能是一项枯燥的任务,但却是我们无法回避的必要工作。测试对于构建可靠程序至关重要,尤其是那些随时间不断变化的程序。它们在帮助设计程序方面也十分有用。测试非常适合让我们从用户和实现者的角度来编写测试。但总的来说,测试往往是重复性的,有时甚至令人感到负担。
来看一个例子:这个测试检查 Erlang 函数能否接收一组已排序的列表,并始终返回合并后的一个单一有序列表:
1merge_test() -> 2 [] = merge([]), 3 [] = merge([[]]), 4 [] = merge([[],[]]), 5 [] = merge([[],[],[]]), 6 [1] = merge([[1]]), 7 [1,1,2,2] = merge([[1,2],[1,2]]), 8 [1] = merge([[1],[],[]]), 9 [1] = merge([[],[1],[]]), 10 [1] = merge([[],[],[1]]), 11 [1,2] = merge([[1],[2],[]]), 12 [1,2] = merge([[1],[],[2]]), 13 [1,2] = merge([[],[1],[2]]), 14 [1,2,3,4,5,6] = merge([[1,2],[],[5,6],[],[3,4],[]]), 15 [1,2,3,4] = merge([[4],[3],[2],[1]]), 16 [1,2,3,4,5] = merge([[1],[2],[3],[4],[5]]), 17 [1,2,3,4,5,6] = merge([[1],[2],[3],[4],[5],[6]]), 18 [1,2,3,4,5,6,7,8,9] = merge([[1],[2],[3],[4],[5],[6],[7],[8],[9]]), 19 Seq = seq(1,100), 20 true = Seq == merge(map(fun(E) -> [E] end, Seq)), 21 ok.
这段代码稍作修改后来自 Erlang/OTP 中 lists 模块的测试套件,这是整个语言中最核心的库之一。开发者在这里试图设想所有可能的使用方式,并确保结果是可预测的。我们或许还能再添加十几行甚至几十行有意义的测试用例,以不同方式探索同一段代码。尽管如此,这段测试代码仍然完全合理、可用、易读且高效。问题在于它太过重复,完全可以由机器自动完成。事实上,这正是传统测试之所以无聊的原因——它们是一组精心安排的指令,告诉机器每次运行哪些测试,毫无变化,仅作为安全检查。
基于属性的测试
正因如此,基于属性的测试成为近年来软件开发领域最令人兴奋的实践之一。它承诺提供比几乎所有其他工具都更优秀、更稳固的测试,而且只需极少的代码量。这意味着,借助这种技术开发的软件质量也会相应提升。虽然这需要较高的学习门槛,但基于属性的测试能让我们自动化那些枯燥的工作,在软件发展和演进过程中持续获得一致的结果。下面是一个等效的基于属性的测试示例:
1sorted_list(N) -> ?LET(L, list(N), sort(L)). 2 3prop_merge() -> 4 ?FORALL(List, list(sorted_list(pos_integer())), 5 merge(List) == sort(append(List))).
这个测试不仅只有四行代码,更加简洁,而且覆盖的场景远超传统测试。实际上,它可以覆盖数十万种情况。目前,这个基于属性的测试看起来可能像一堆难以理解的乱码——至少在没有 PropEr 框架的情况下无法执行——但随着时间推移,它应该会变得比长篇的传统测试更容易阅读和理解。
在本章中,我们将了解基于属性测试应带来的预期效果,探讨其背后的原理以及这些原理如何影响我们的测试编写方式。我们还将介绍开始实践所需的工具,因为正如我们将看到的,基于属性的测试需要一个框架才能真正发挥作用。
基于属性测试的潜力
基于属性的测试与传统测试截然不同,需要更多的思考。非常多。优秀的基于属性测试是一种需要学习和不断练习的技能,就像演奏乐器或使用画笔一样。我们永远有可以改进的空间,也总能找到创新测试方法的新途径。
尽管掌握这项技能可能需要数年时间,但对于初学者来说,已经有很多显著的好处。我们可以写出简单、简短而精炼的测试,自动遍历我们的代码。即使我们在不修改测试的前提下对程序进行修改,代码覆盖率依然保持高水平且稳定。我们甚至可以利用这些测试发现新的边界情况,而无需额外改动。
随着经验积累,我们还能为有状态系统编写清晰的集成测试,从而发现最复杂、最隐蔽的缺陷。
示例 1:Project FIFO
总体而言,我们会发现基于属性的测试不仅仅是使用一系列工具来自动化枯燥任务,它实际上是一种全新的测试和软件设计思维方式。例如,Thomas Arts 在 Erlang Factory 2016 会议上的幻灯片和演讲中提到,他们使用了经典的基于属性测试工具 QuickCheck,对开源云项目 Project FIFO 进行测试。仅用 460 行属性测试代码,就覆盖了 6 万行生产代码,并发现了 25 个重要缺陷,包括:
- 时间相关错误
- 竞态条件
- 类型错误
- 库 API 的误用
- 文档错误
- 程序逻辑错误
- 系统限制错误
- 故障处理错误
- 一个硬件错误
考虑到一些研究估计开发者平均每千行代码存在六个软件缺陷,仅用 460 行测试就发现 25 个重要缺陷,这无疑是一项非凡成就。相当于每千行测试代码发现超过五十个缺陷,而每行测试平均覆盖了 140 行生产代码。
示例 2:Google 的 levelDB
再来看一个更高级的应用案例。Joseph Wayne Norton 使用不到 600 行的 QuickCheck 测试套件,对 Google 的 levelDB 进行分析,成功找出两组特定调用序列:第一组是前十七次调用,第二组是另外三十一次特定调用,这些组合会导致数据库出现“幽灵键”(ghost keys)的损坏。无论一个人多么专注,要手动构造出这三十一次调用的正确顺序来破坏数据库,几乎是不可能的。
再次说明,我们仅用极少量的代码,就在原本已经过充分测试并投入生产的软件中,发现了大量非平凡的错误。基于属性的测试如此强大,以至于已在多个行业中扎根:
- 关键任务通信组件
- 数据库系统
- 云服务提供商的路由模块
- 证书管理层
- 物联网平台
- 汽车系统
请记住,基于属性的测试并非仅限于高级程序员。提升所需的努力是持续不断的,但其带来的收益显然值得付出。请始终记得,每一次遇到的障碍,都是改进的机会。我们一步步前行,终将抵达目标。