易于测试的代码
软件IC是人们在讨论可复用性和基于组件的开发时最喜欢使用的比喻。意思是软件组件应该就像集成电路一样进行组合,这只有在你使用的组件已知是可靠地时候才能行之有效。
芯片在设计时就考虑了
测试——不只是在工厂,在安装时,而且也是在部署现场进行测试。更加复杂的芯片和系统可能还拥有完整的Built-In-SelfTest(BIST)特性,用于在内部运行某种基础级的诊断;或是拥有
Test Access Mechanism(TAM),用以提供一种测试装备,允许外部环境提供激励,并收集来自芯片的响应。
我们可以在软件中做同样的事情,与我们的硬件同事一样,我们也需要从一开始就把可测试性(testability)构建进软件中,并且在把各个部分连接在一起之前对每个部分进行彻底的测试。
单元测试
硬件的芯片级测试大致等价于软件中的单元测试(unit testing)——在隔离状态下对每个模块进行测试,目的是检验其行为。一旦我们在受控的(甚至是人为的)条件下对模块进行了彻底的测试,我们就能够更好地了解模块在广阔的世界上将怎样起反应。
软件的单元测试时对模块进行演练的代码。在典型情况下,单元测试将建立某种人工环境,然后调用被测试模块中的例程。然后,它根据已知的值,或是同一测试先前返回的结果(回归测试),对返回的结果进行检查。
随后,当我们把我们的“软件IC”装配进完整系统中时,我们将有信心,各个部分都能够如预期的那样
工作,然后我们可以使用同样的单元测试设施把系统当做整体进行测试。
但是,在我们走那么远之前,我们需要决定在单元级测试什么,在典型情况下,程序员会随便把一些数据扔给代码,就说已经测试过了,应用“按合约设计”后面的思想,我们可以做得好得多。
针对合约进行测试
我们喜欢把单元测试视为针对合约的测试。我们想要编写
测试用例,确保给定的单元遵守其合约。这将告诉我们两件事情:代码是否符合合约,以及合约的含义是否与我们所认为的一样。我们想要通过广泛的测试用例与边界条件,测试模块是否实现了它允诺的功能。
我们为什么要这么费事?最重要的是,我们不想制造“定时炸弹”——呆在周围不被人注意,却在项目最后的尴尬时刻爆炸的东西。通过强调针对合约进行测试,我们可以设法尽可能多的避免那些“下游的灾难”(downstream disaster)。
提示
Design to Test 为测试而设计
当你设计模块,或是单个例程时,你应该既设计其合约,也设计测试改合约的代码。通过设计能够通过测试,并履行其合约的代码,你可以仔细地考虑边界条件和其他非如此不会发现的问题。没有什么修正错误的方法比一开始就避免发生错误更好。事实上,通过在你实现代码之前构建测试,你必须在你确定采用某个接口之前先对它就行试验。
编写单元测试
模块的单元测试不应被扔在源码树的某个遥远的角落里。他们须放置在方便的地方。对于小型项目,你可以把模块的单元测试嵌入在模块自身里。对于更大的项目,我们建议你把每个测试都放进一个子目录。不管是哪种方法,要记住,如果你不容易找到它,也就不会使用它。
通过使测试代码易于找到,你是在给使用你代码的开发者提供两样 无价的资源:
1. 一些例子,说明怎样使用你的模块的所有功能。
2. 用以构建回归测试,以验证未来对代码的任何改动是否正确的一种手段。
让各个类或模块包含自己的单元测试很方便(但却并非总是可行)。例如,在Java中,每个类都有自己的main,除了在应用的主类文件里,所有的main例程都可用于运行单元测试,当应用者自身运行时它将被忽略。这样做的好处是,你交付的代码仍然含有测试,可用于在现场对问题进行诊断。
在C++中,通过使用#ifdef有选择的编译单元测试,你可以(在编译时)获得同样的效果。
但是只提供单元测试还不够,你还必须运行它们,并且经常运行它们,如果类偶尔通过了测试,那也是有帮助的。
使用测试设备
因为我们通常会编写大量测试代码,并进行大量测试,我们要让自己的生活容易一点,为项目开发标准的测试设备(testing harness)。前一节给出的main函数是非常简单的测试装备,与之相比,我们通常需要更多的功能。
测试装备可以处理一些常用操作,比如记录状态,分析输出是否符合预期的结果,以及选择和运行测试,装备可以由GUI驱动,可以用项目的其他部分所用的语言编写,也可以实现为makefile或Perl脚本的组合。
在面向对象语言和环境中,你可以创建一个提供这些常用操作的基类。各个测试可以对其进行继承,并增加专用的测试代码。你可以使用Java中的标准名称约定和反射,动态的创建测试列表。这一技术是遵循DRY原则的好方法——你无需维护可用测试的列表。但是你出发前编写自己的装备前,你可以研究一下Kent Beck和Erich Gamma的xUnit。他们已经完成了这项艰苦的工作。
不管你决定采用的技术是什么,测试装备都应该具有以下功能:
●用以指定设置与清理(setup and cleanup)的标准途径。
●用以选择个别或所有可用测试的方法。
●分析输出是否是预期(或意外)结果的手段。
●标准化的故障报告形式。
测试应该是可以组合的,也就是说,测试可以由子组件的子测试组合到任意深度。通过这一特性,我们可以使用同样的工具,同样轻松地测试系统的选定部分或整个系统。
构建测试窗口
即使是最好的测试集也不大可能找出所有的bug;工作环境的潮湿、温暖的状况似乎能把它们从木制品中带出来。
这就意味着,一旦某个软件部署之后,你常常需要对其进行测试——在现实世界的数据正流过它的血脉时。与电路板或芯片不同,在软件中我们没有测试管脚(test pin),但我们可以提供模块的内部状态的各种视图,而又不使用调试器(在产品应用中这可能不方便,或是不可能)。
含有跟踪消息的 日志文件就是这样一种机制。日志消息的格式应该正规、一致,你也许想要自动解析它们,以推断程序所用的处理时间或逻辑路径。格式糟糕或不一致的诊断信息就像是一堆“呕吐物”——它们既难以阅读,也无法解析。
了解运行中的代码内部状况的另一种机制是“ 热键”序列。按下特定的键组合,就会弹出一个诊断控制窗口,显示状态消息等信息。你通常不会想把这样的热键透露给最终用户,但这对于客户服务人员却非常方便。
对于更大、更复杂的服务器代码,提供其操作的内部视图的一种漂亮技术是使用 内建的web服务器,任何人都可以让Web浏览器指向应用的HTTP端口(通常使用的是非标准端口号,比如8080),并看到内部状态、日志条目、甚至可能是某种调试控制面板,这听起来也许难以实现,其实并非如此。你可以找到各种现代语言编写,可自由获取、可嵌入的HTTP Web服务器。
测试文化
你编写的所有软件都将进行测试——如果不是由你和你们团队测试,那就要由最终用户测试——所以你最好计划好对其进行彻底的测试,一点预先的准备可以大大降低维护费用、减少客户服务电话。
尽管有着黑客的名称,Perl社区对单元测试和回归测试非常认真,Perl的标准模块安装过程支持回归测试(%make test)。在这方面Perl自身并无任何神奇之处,Perl使得比较和分析测试结果都变得更为容易,以确保顺应性(compliance)——测试被放在指定的地方,并且有着某种预期的输出。测试是技术,但更是文化;不管所用语言是什么,我们都可以让这样的测试文化慢慢渗入项目中。