由 Stephen Vance 所撰写的《高质量代码——软件测试原则、实践与模式》一书涵盖了软件开发生命周期的各个方面,尤其关注于提交高质量的产品。Stephen 在本书中谈论了为支持软件技术水准测试所需的一些实践。通过一些简单的代码示例,他为我们展示了一些设计技巧,例如如何从具体实现中分离出意图。本书还探讨了以下一些测试原则:
- 对意图的校验胜于具体实现
- 尽量使测试类保持最小化、干净并且运行时间短
- 编写短小的测试
- 关注分离
Stephen 在书中也涵盖了可测试性模式等方面的内容,此外还描述了验证竞争条件或死锁等情况的并行测试技巧。
InfoQ 有幸与作者进行了交流,对本书的内容以及测试应用程序代码的最佳实践进行了一番讨论。
InfoQ:首先能否请您为读者们对“高质量代码”作一个定义呢?因为对不同的读者来说,它可能意味着不同的东西。那么究竟什么是高质量的代码呢?
Vance:如果要用一句话来概括的话,我认为高质量代码具有以下特性,它们按重要性的优先顺序排列:高质量代码能够完成既定的任务、没有 bug,并且实现良好。它不仅能够应对今天的需求,并且在明天甚至是明年依然能够胜任;它能够满足业务和用户的需求;它应该是没有 bug 的,并且应当尽量与当前这个不完美的世界保持独立,而如果它不得不和某个不完美的外部世界相交互,也能够优雅地进行处理;它应该具有良好的设计与实现,即使在多年之后也能够轻松地修订、改动或增强其行为。传统上每过若干年之后,某些软件中的老代码的修改成本就会超过重写它的成本了,而高质量的代码则有希望打破这个怪圈。
我假设你已经了解你的代码需要完成的功能是什么,我将试图为你解决的问题是怎样将这个需求如实地转变为代码。尤其重要的是,我将确保开发者了解可测试的代码是怎样的,并让他们了解他们可以从测试代码的实现模式中熟练掌握这一技巧。
InfoQ:您能够深入谈论一下“代码的意图”吗?如果某个 IDE 工具能够提供一种管理代码意图的特性,它又应该是怎样工作的?
Vance:你所指的是我在本书第 2 章介绍部分所引用的某个虚构的“意图检查器”(Intention Checker),对吧?
很多时候,我们在完成了代码编写之后,就简单地判断它是否按照我们想要的方式工作。但其实我们并非完全有意图地编写我们的代码。或者这段代码虽然并不算实现的非常差,但它确实存在 bug,而且很可能包含了许多不必要的元素。而有意图的编写代码是指你完全理解编写每一条语句的原因,并且保证每一条语句都是为了满足某个目的而编写的。
在整本书中,我对于这一点始终非常关注,因为一个测试就是一个意图的语句表达。对这一点的认知将有助于提高你的测试功力,并且编写出满足需求的代码。
正因如此,我本人是测试驱动开发的忠实粉丝。因为这门技术不是针对测试本身,而是通过编写一个能够表达你意图的测试来驱动你的设计。
说到工具方面,如果我真有答案的话,那我想整个软件工程业界应该也都知道这个答案了吧。将意图转换为代码是编程过程的真正核心。业务曾试用过各种手段来实现这一点,包括需求、可追踪性、图形化编程、软件工厂、形式逻辑、生成框架以及其它种种方式,但始终没有很好地做到表现语义。我们在慢慢地开发越来越高级别的构件,它使我们越来越远离本质的部分。但为了编写软件所必须进行的分解的级别依然远远高于多数人所能够理解的粒度,这一过程依然是高度专业性,而且极易出错的。
InfoQ:对于应用程序中的错误处理代码,您有些什么心得体会吗?
Vance:你的软件除了正确地完成了基本的功能之外,它是否进行了合适的错误处理可以说是对软件品质的最大的决定因素了。我听到过一种说法,当人们考虑是否购买或使用某样东西的时候,1 条差评的分量往往能够顶得上 4 到 10 条好评。
那么为什么错误处理这一步骤往往会放到最后才去考虑,为什么在设计时没有注意它,并且对它的测试覆盖率也是最低的呢?为什么在许多用户可见的错误中,会显示一些隐晦的或无用的信息呢?其中一部分原因是在于没有有意识地对错误用例进行处理或者测试。
从代码角度来说,像 Java 这样的语言即使能够声明异常,它也不会强制你必须声明所有的异常。开发者越来越倾向于仅仅使用 Java 中的 unchecked 异常类型,它允许你忽略各种显式的意图。更不用说还有许多语言不支持对错误的声明。并且许多类库和框架也没有很好地将各种错误文档化,这意味着即使你打算在自己的软件中很好地处理它,最终结果也取决于你能在多大程度上对这些工具进行反向工程。
InfoQ:您在书中谈论了多种不同的测试方面,例如状态测试与行为测试。您是否能够深入地讲述一下这些内容,并谈谈开发者应该如何利用这些不同的测试技术吗?
Vance:状态测试关注的是你的软件行为会造成数据产生怎样的变化。这方面的经典例子包括:完全由输入参数的值所决定的返回值;以及在执行对象上的某个方法后对对象属性值的改变。
行为测试则关注在软件执行时调用了哪些方法,例如调用了其它对象的方法,或是调用了某个服务。举例来说,如果某个方法的唯一目的就是以正确的顺序及正确的参数调用其它方法,那么你就需要验证这些方法调用是否按照期望进行执行了。
某些方法或许能够完全归类于状态测试或行为测试中的一种,但多数方法与两者都有相关。对于这些方法来说,如果仅仅按照一种方法测试,不仅有局限性,甚至可能会有所损害。你不一定能够访问所有状态,而且仅仅关注状态或者会使你遗漏某些关键的行为。而过于关注行为的风险是你的测试有可能会与实现细节相耦合,并且为了支持这种风格的验证,你或许会对软件进行过度设计。
如果想在两方面都获得最好的效果,那么你需要有意图地指引你的测试,并且让你使用的测试技巧能够最好地表达出你的意图,而且让你的测试与具体实现的耦合降至最低。这两者之间的差别应该能够进一步强化你的设计意图,而不是非此即彼的相互排斥。
InfoQ:在创建足够的测试方法与过分追求覆盖率之间如何进行平衡?
Vance:对这个问题可以从多种角度来回答。一种衡量角度是你是否编写了冗余的测试,另一种角度则是测试反馈周期影响时间有多长,例如是否由于你编写了过多的测试而使你的测试集运行起来不够快了。还有一种角度依赖于测试的级别,例如单元 / 隔离测试、集成测试、API 测试和系统测试
我想你的问题是我所关注的那个角度,它也是关注软件测试的各种讨论中的核心部分,也就是使你的投入保持协调。其实这种说法的另一种含义,即是说对这个问题没有明确的答案,取决于实际情况。
如果你遵循测试驱动开发的方式,那么这个问题的答案与你仅仅追求测试覆盖率又有所不同,至少在开发的首个阶段来说是不同的。测试驱动开发更接近于一种软件开发的途径,而不是软件测试的方法。这种高度纪律性的开发方式驱使在编写实际的代码之前,用某个测试表现出你的意图,随后不断改进你的软件,通过这种方式创建出高质量的代码。
从测试覆盖率的角度来说,它主要是与风险评估相关。假设你来自一家创业公司,正在努力寻找自己的商业模式。如果你目前正在编写的软件在下一周就有可能被废弃,那你还愿意投入大量精力去确保这个软件毫无漏洞吗?另一方面,你能否在一定程度上保证你的软件质量,至少不要因为缺乏测试而让那些 bug 吓跑了你的潜在客户呢?如果你已经认识到你的软件缺乏足够的测试,而你也确定了你的商务模式,在这种情况下,你是否会按照更具有纪律性的方式去重写软件,填补缺失的代码覆盖率,以达到一种可接受的程度?或者是放任不管,而寄希望于软件本身的质量已经足够好了呢?此时,个人和公司对风险的容忍度将指导你做出决策。
再举一个例子,如果你接手了一个完全没有测试的软件项目,但该项目已经几乎不需要改动了,或是很快就将被取代了,那又怎么样呢?你很可能不会为这个系统编写完整的测试。不过,某些关键的函数或较高层次的改动依然需要测试。
典型的软件系统往往支持着一个正在运行中的商务模型,而且这个模型在未来几年内也需要继续运行,这样的系统就需要测试。有趣的是,我发现单元测试只有在语句覆盖率达到至少 50% 以上才能值会投入成本,至少要到 70% 至 80% 的语句覆盖率才能够体现出明显的好处。我曾经为一些具有高可靠性和高安全性的软件系统做过测试方面的培训,这些系统做到了 100% 的语句、分支和条件覆盖率这几个里程碑,它们对于测试系统故障确实起到了极大的作用。
有些观点在本书中并没有提到,其中之一就是追求高测试覆盖率并没有通常所想象的那么困难。实际上,本书曾考虑过以“高覆盖率的单元测试”命名。对许多来说,困难主要在于对可测性机制的理解。
InfoQ:您在本书最后一章讨论了软件考古学(Software Archaeology),能否请您讨论一下这方面内容,以及它对保证软件质量起到了怎样的帮助吗?
Vance:如果你没有任何测试或文档,那你不得不对你的意图进行反向工程,以得到待测试的系统(software under test)。有时你会发现,你对软件的产出感到力不从心,这让你感觉很受挫。有些时候,这是由于你的技能、洞察力或注意力的局限所造成的。但通常来说,真正的问题出自于退化的代码、不断变化的语境、无效的行为和莫名奇妙的临时方案等方面。
在你所提到的那一部分软件功能中,我无法找到某个异常是从哪里产生的。那段代码会根据异常信息做出一些处理,按照文档中的方法,它在这里使用了一些 hack 手段。我可以通过注入一个在表面上看来满足其预期模式的异常对象来完成测试方法,但这样一来,我所测试的就不是软件的意图,而是它具体的实现方式了。
经过仔细研究后才知道,原来这种奇怪的处理方式来自于一个早已被重构过的方法的老版本,异常处理代码也已经重新实现了。但问题在于老版本的代码依然留在系统中,结果就导致了无用的并且令人困惑的代码,而且难以进行测试。这些都属于软件质量方面的反模式,我们需要使用一些考古学的方法将其清除出去。
对代码从始至今的演化过程进行研究,在我们需要填补测试方法时,有助于使代码保持精准、整洁和易于测试。
Stephen 还提到了以下内容,他认识测试的局限性往往是由糟糕的设计造成的,而不是由可测试性本身造成的。
“我最喜欢的一句名言来自于亨利福特(福特公司创始人):‘无论你认为你行或者你不行,你都是对的!’。这句话对于测试来说同样成立。如同我在书中第 13 章所描述的,即使是一种最困难的测试,即重现某个竞态条件,也能够以大量的列举情境来驯服。在测试方面的困难更像是一种坏味道,它暗示着被测试的代码出现了问题,而不是可测试性本身的问题。”
关于作者
Stephen Vance在过去的 20 年中几乎扮演过软件开发过程中每一个相关的角色。他曾为多个不同的产业机构解决过虚拟现实、工业机器人、互联网基础结构、企业商务和软件即服务等方面的问题。他经常在全球范围内对软件开发流程和配置管理方面提供顾问服务、举办培训和进行演讲。他现在在波士顿担任精益 / 敏捷软件开发的教练
评论