(摘要)
[TOC]
自动化测试的价值 开发者应该编写自动化测试,以便当发现回归问题时就使构建失败。而且,测试先行的编程风格已有大量的专业研究,使用自动化测试不仅是保护回归,而且是帮助设计,在编写代码之前就指出代码的期望行为,从而在验证实现之前先验证设计。
来认识一下小马。小马是著名的编程奇才,两年前刚毕业,然后加入了本地一家投行的IT部门,为银行开发用于在线自助服务的web应用程序。作为团队中最年轻的成员,起初他保持低调,集中精力学习银行的领域知识,熟悉“这里做事的方式”。
几个月后,小马注意到团队的工作很多都是返工 :修复程序员的错误。他开始关注团队修复错误的类型,发现单元测试可以轻易地捕获到其中大多数。当他感觉到哪里存在特别容易出错的代码时,小马就对其编写单元测试。
测试帮助我们捕获错误。
一段时间以后,团队其他人也开始到处编写单元测试。Marcus已被测试感染 (test-infected) 了,他碰过的代码几乎都具有相当高的自动化测试覆盖率。除了在第一次时犯错,他们不会再花费时间修复过去的错误,待修复缺陷的总数在下降。测试开始清晰可见地影响着团队工作的质量。
自从小马编写第一个测试,已经过去近一年了。团队的测试覆盖率在近几个月快速提高,达到了98%的分支覆盖率。小马曾认为他们应该推动那个数字直到100%。但过去几周,他打定了主意——那些缺失的测试不会给他们带来更多价值,花费更多精力来编写测试不会带来额外的收益。许多没有测试覆盖的代码,只因所用的API要求实现某些接口,但小马的团队并未用到它们,那么何必测试那些空方法桩呢?
100%测试覆盖率不是目标
100%的覆盖率并不能够确保没有缺陷——它只能保证你所有的代码都执行了,不论程序的行为是否满足要求。与其追求代码覆盖率,不如将重点关注在确保写出有意义的测试。
后来,高级软件架构师老赛加入了自助服务团队,并快速地成为了低级成员的导师,包括小马在内。小马习惯于先写代码,在提交到版本控制系统之前再补充单元测试。但老赛的风格是先写一个会失败(很明显是这样)的测试,再写足够使测试通过的代码,然后写另一个失败的测试。他重复这个循环直到完成任务。
与老赛共事,小马意识到自己的编程风格开始转变。他的对象结构不同了,代码看起来稍微不同了,只是因为他开始从调用者的角度来审视代码的设计和行为了。
测试帮助我们针对实际使用来塑造设计。
想到这些,小马觉得好像明白了一些道理。当他试图用语言表达出他的编程风格是如何改变的,以及收到了哪些效果时,小马明白了他写的测试不仅仅是质量保证的工具,或是对错误及回归的保护。测试作为一种设计代码的方式 ,提供了具体的目的。编写使失败测试通过的代码比以前的方式简单多了,而且该做的也都做了。
通过明确地指出所需的行为,测试帮助我们避免镀金。
主人公小马的经历正如许多爱好测试的编程人员一样,在不断地认识和理解测试。我们经常因为相信单元测试有助于避免尴尬、耗时的错误而开始编写它们。随后我们会学到,将测试作为安全网只是等式的一部分,而另一部分——或许是更大部分——其好处是我们将测试表达为代码的思考过程。
大多数人同意说编写一些 测试是不费脑筋的。但随着我们接近完全的代码覆盖率,我们不那么确定了——我们差不多已经为一切都编写了测试,而剩下的没有测试的代码是微不足道,几乎不会破坏。这就是某些人所谓的收益递减。因此,
编写测试的最大价值不在于结果,而在于编写过程中的学习。
(略,请阅读正版原书)
何为优秀与代码坏味道 可读性、可维护性、可信赖性…
单元测试代码的可读性 原始类型断言 断言应该表达某种假设或意图。它们应该声明代码的行为。基本断言的问题在于它们缺乏意义,因为断言的基本原理和意图隐藏在看上去无意义的单词和数字背后,造成它们难以理解,并且难以验证断言的正确性。
下面例子展示了一个**全局正则搜索(grep)**程序的测试。grep程序其实就是逐行处理某种文本输入,在处理时可以包括或排除掉含有特定子串或模式的文本行。测试应当是你的对象所提供功能的具体例子,那么我们来看一看这个测试是否说清楚了grep程序该做的事情。但愿这个测试没有受到原始类型断言的折磨!
1 2 3 4 5 6 7 @Test public void outputHasLineNumbers () { String content = "1st match on #1\nand\n2nd match on #3" ; String out = grep.grep("match" , "test.txt" , content); assertTrue(out.indexOf("test.txt:1 1st match" ) != -1 ); assertTrue(out.indexOf("test.txt:3 2nd match" ) != -1 ); }
这个测试在干什么?首先,我们定义了文本字符串content
来表示一段输入,然后调用grep程序,然后对grep结果输出中的某些东西进行断言。这些东西 就是问题所在。断言的目标并不清楚,因为断言过于基本——它与测试的其他部分说着不同的语言。
具体来说,我们在这个断言中是要计算输出结果中的另一个文本字符串的索引,并将其与-1
进行比较;如果索引为-1
,那么测试失败。测试的名字叫做outputHasLineNumbers
,显然,对于在代码中grep()的具体调用,输出结果应当包含正在进行逐行查找的文件名,后面加上行号。
不幸的是,我们不得不经过这个思考过程才能理解为什么我们要计算索引,为什么查找test.txt:1
而不是其它东西,为什么与-1
进行比较,当索引是-1
或不是-1
时测试是否会失败?这无需火箭科学就能找到答案,但却是我们大脑的不必要的认知工作。
过度断言 过度断言 是如此谨慎地敲定每个待检查行为的细节,以至于它变得脆弱,并且掩盖了整体广度和深度之下的意图。当你遇到过度断言,很难说清它要检查什么,并且当你退后一步观察,会看到测试打断的频率可能远超平均水平。它如此挑剔,以至于无论任何变化都会造成输出与期望不同。
下例中,公司希望收集到销售人员如何实施演示的日志数据,记录哪个销售人员展示了哪页讲义,以及停顿时间等。在与中央服务器同步日志文件之前,我们转换日志文件的格式,对其进行预处理,使中央服务器更加容易切割日志文件并将数字转存到中央数据库。我们利用时间戳计算幻灯片持续时间。负责这种转换的对象叫做LogFileTransformer
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class LogFileTransformerTest { private String expectedOutput; private String logFile; @Before public void setUpBuildLogFile () { StringBuilder lines = new StringBuilder (); appendTo(lines, "[2005-05-23 21:20:33] LAUNCHED" ); appendTo(lines, "[2005-05-23 21:20:33] session-id###SID" ); appendTo(lines, "[2005-05-23 21:20:33] user-id###UID" ); appendTo(lines, "[2005-05-23 21:20:33] presentation-id###PID" ); appendTo(lines, "[2005-05-23 21:20:35] screen1" ); appendTo(lines, "[2005-05-23 21:20:36] screen2" ); appendTo(lines, "[2005-05-23 21:21:36] screen3" ); appendTo(lines, "[2005-05-23 21:21:36] screen4" ); appendTo(lines, "[2005-05-23 21:22:00] screen5" ); appendTo(lines, "[2005-05-23 21:22:48] STOPPED" ); logFile = lines.toString(); } @Before public void setUpBuildTransformedFile () { StringBuilder file = new StringBuilder (); appendTo(file, "session-id###SID" ); appendTo(file, "presentation-id###PID" ); appendTo(file, "user-id###UID" ); appendTo(file, "started###2005-05-23 21:20:33" ); appendTo(file, "screen1###1" ); appendTo(file, "screen2###60" ); appendTo(file, "screen3###0" ); appendTo(file, "screen4###24" ); appendTo(file, "screen5###48" ); appendTo(file, "finished###2005-05-23 21:22:48" ); expectedOutput = file.toString(); } @Test public void transformationGeneratesRightStuffIntoTheRightFile () throws Exception { TempFile input = TempFile.withSuffix(".src.log" ).append(logFile); TempFile output = TempFile.withSuffix(".dest.log" ); newLogFileTransformer().transform(input.file(), output.file()); assertTrue("Destination file was not created" , output.exists()); assertEquals(expectedOutput, output.content()); } }
第一个断言检查目标文件是否创建。第二个断言检查目标文件的内容符合期望。现在,第一个断言的价值值得商榷,而且很可能需要删除。但我们主要关注第二个断言——过度断言: assertEquals(expectedOutput, output.content());
它精确地验证了测试名称所暗示的内容,这是个重要的断言。问题是这个测试太宽泛了,导致断言对整个日志文件进行大规模比较。它是一张厚厚的安全网,这毫无疑问,即使是输出中最微小的变化也会使断言失败。也正是这里存在问题。
永不失败的测试没有价值——它可能没有测试任何东西。而在另一个极端,总是失败的测试也很烦人。我们需要的是一个过去曾经失败的测试——证明它能够抓住与被测代码的预期行为之间的偏差——并且如果我们改动被测代码它就会再次失败。
本例中的测试太容易失败而变得脆弱,因此没能满足这条标准。但那只是更基本问题的症状——过度断言的问题。日志文件格式或内容的微小变化都是测试失败的原因。断言并无本质错误。问题在于测试违反了构成优秀测试的基本指导原则。
一个测试应该只有一个失败的原因。
这个原则看起来眼熟,其实它是著名的面向对象设计原则的变体,也即单一职责原则(SRP)所说的“一个类应该有且只有一个改变的理由”。
解法:
当我们碰到过度细节的断言成瘾,第一步行动是识别无关细节并将其从测试中移除。本例中,你会查看要转换的日志文件,尝试减少行数。
我们希望它代表一份有效的日志文件,并且对于测试目的来说足够详尽。例如,日志文件具有对五个屏幕的计时。也许两三个就够了?我们能否只用一个?这个问题使我们考虑下一个改进:拆分这个测试。
问问自己,当不再测试x、y或z情况下,能够迅速通过测试所需的最小日志文件要多少行。这里x、y或z都是可以单独进行测试的主要候选者。下面展示一种可能的解决方案,将日志文件的每个方面及其转换都抽取到单独的测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public class LogFileTransformerTest { private static final String END = "2005-05-23 21:21:37" ; private static final String START = "2005-05-23 21:20:33" ; privateLogFilelogFile; @Before public void prepareLogFile () { logFile = new LogFile (START, END); } @Test public void overallFileStructureIsCorrect () throws Exception { StringBuilder expected = new StringBuilder (); appendTo(expected, "session-id###SID" ); appendTo(expected, "presentation-id###PID" ); appendTo(expected, "user-id###UID" ); appendTo(expected, "started###2005-05-23 21:20:33" ); appendTo(expected, "finished###2005-05-23 21:21:37" ); assertEquals(expected.toString(), transform(logFile.toString())); } @Test public void screenDurationsGoBetweenStartedAndFinished () throws Exception { logFile.addContent("[2005-05-23 21:20:35] screen1" ); String out = transform(logFile.toString()); assertTrue(out.indexOf("started" ) <out.indexOf("screen1" )); assertTrue(out.indexOf("screen1" ) <out.indexOf("finished" )); } @Test public void screenDurationsAreRenderedInSeconds () throws Exception { logFile.addContent("[2005-05-23 21:20:35] screen1" ); logFile.addContent("[2005-05-23 21:20:35] screen2" ); logFile.addContent("[2005-05-23 21:21:36] screen3" ); String output = transform(logFile.toString()); assertTrue(output.contains("screen1###0" )); assertTrue(output.contains("screen2###61" )); assertTrue(output.contains("screen3###1" )); } private String transform (String log) { ... } private void appendTo (StringBuilder buffer, String string) { ... } private class LogFile { ... } }
位操作的断言 位(bit)操作是计算机的底层运算符,抽象层次低从而显得如此模糊,以至于其意图和意义对读者来说是个谜。
1 2 3 4 5 6 public class PlatformTest { @Test public void platformBitLength () { assertTrue(Platform.IS_32_BIT ^ Platform.IS_64_BIT); } }
这里我们要检查什么?断言包含了描述,它断言两个布尔 值的“^位运算”结果为真 。位运算符做了什么?什么时候断言会失败?
如果你恰好工作在一个相对底层的领域,经常与位运算打交道,你会很快推断出我们在进行一次XOR(异或)运算,意味着如果位运算符两边的布尔 值相同时,断言就会失败。
位运算符正是测试的问题所在。位运算符是用于位和字节运算的强大语言特性。但在这个测试的上下文中,我们并非处于位和字节的领域。我们处于“我们应该运行在32位或64位架构”的领域,而精髓隐藏在无关的位运算符背后。
那个特殊的位运算符完全适合去有效地执行一个简洁的断言,但我们并不是在优化测试断言。我要确保我们正确地构建正确的事情——我们的代码做我们期望的事情,同时我们的期望是有道理的。出于这个目的,必然有一个更好的方式来表达我们的意图,而不是位运算符。
解法:
用一个或多个布尔运算符来替换位运算符,清晰地依次表达期望。
1 2 3 4 5 6 7 8 9 public class PlatformTest { @Test public void platformBitLength () { assertTrue("Not 32 or 64-bit platform?" , Platform.IS_32_BIT || Platform.IS_64_BIT); assertFalse("Can’t be 32 and 64-bit at the same time." , Platform.IS_32_BIT && Platform.IS_64_BIT); } }
它变得更加冗长,但断言的表述也更加明确。在这种情况下,纯英文消息非常有助于理解。
次要的细节 代码可读性源于快速和忠实地向读者揭示意图、目的和意义。当程序员扫视一段代码,他们是在寻找牛肉,而不想要酱汁。有时我们的测试代码充满了太多的酱汁。
JRuby有个模块叫ObjectSpace,令你的程序访问到所有运行期生存的对象。例如,你可以遍历某种类型的所有对象。下列代码表示了ObjectSpace的一个测试,用于确保按类型查找的代码能正常工作。你能迅速说出这个测试在做什么吗?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class TestObjectSpace { private Ruby runtime; privateObjectSpaceobjectSpace; @Before public void setUp () throws Exception { runtime = Ruby.newInstance(); objectSpace = new ObjectSpace (); } @Test public void testObjectSpace () { IRubyObject o1 = runtime.newFixnum(10 ); IRubyObject o2 = runtime.newFixnum(20 ); IRubyObject o3 = runtime.newFixnum(30 ); IRubyObject o4 = runtime.newString("hello" ); objectSpace.add(o1); objectSpace.add(o2); objectSpace.add(o3); objectSpace.add(o4); List storedFixnums = new ArrayList (3 ); storedFixnums.add(o1); storedFixnums.add(o2); storedFixnums.add(o3); Iterator strings = objectSpace.iterator(runtime.getString()); assertSame(o4, strings.next()); assertNull(strings.next()); Iterator numerics = objectSpace.iterator(runtime.getNumeric()); for (inti = 0 ; i< 3 ; i++) { Object item = numerics.next(); assertTrue(storedFixnums.contains(item)); } assertNull(numerics.next()); } }
我们在测试ObjectSpace
的行为,我们首先向其中“添加”某种类型的对象,然后请求该类型的迭代器(Iterator),检查是否拿回了那些对象。但人脑一次就只能应付这么多概念,而跟踪所有那些列表和迭代器就实在太多了。
解法:
测试的本质应当是开门见山,这可以归结为几个简单的准则:
将不必要的安装(setup)抽取到私有方法或安装部分。
起个恰当的、描述性的名字。
一个方法内尽量只有一个抽象层次。
首先,我们改为在安装方法中创建那4个对象,将局部变量转为测试类的字段。毕竟,他们只是测试的道具。其次,由于测试对两类对象区别对待——无论是“Fixnums”还是“Strings”——我们应当照此命名以凸显类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class TestObjectSpace { ... privateIRubyObject string; private List<IRubyObject>fixnums; @Before public void setUp () throws Exception { ... string = runtime.newString("hello" ); fixnums = new ArrayList <IRubyObject>() {{ add(runtime.newFixnum(10 )); add(runtime.newFixnum(20 )); add(runtime.newFixnum(30 )); }}; } ... }
现在我们将重点移到测试方法本身,以及第三条准则:一个方法内尽量只有一个抽象层次。说到抽象,在这个测试中我们关心的是,一旦我们向ObjectSpace中添加了某些对象,可以精确地通过各自的迭代器找到那些对象。清单4.12中显示了可读性方面的明显改进,仅仅提供单一的抽象层次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class TestObjectSpace { private Ruby runtime; privateObjectSpace space; privateIRubyObject string; private List<IRubyObject>fixnums; @Before public void setUp () throws Exception { runtime = Ruby.newInstance(); space = new ObjectSpace (); string = runtime.newString("hello" ); fixnums = new ArrayList <IRubyObject>() {{ add(runtime.newFixnum(10 )); add(runtime.newFixnum(20 )); add(runtime.newFixnum(30 )); }}; } @Test public void testObjectSpace () { addTo(space, string); addTo(space, fixnums); Iterator strings = space.iterator(runtime.getString()); assertContainsExactly(strings, string); Iterator numerics = space.iterator(runtime.getNumeric()); assertContainsExactly(numerics, fixnums); } private void addTo (ObjectSpace space, Object... values) { } private void addTo (ObjectSpace space, List values) { } private void assertContainsExactly (Iterator i, Object... values) { } private void assertContainsExactly (Iterator i, List values) { } }
这已经好很多了;随着所有安装和细节从测试方法本身移出去,主要部分显露了出来。另外,你也可以试着给夹具对象起个更具描述性的名字,而不必将它们移到安装方法,看看是否那样就已经足够修复附加细节的坏味道。毕竟,只有一个测试用到那些夹具对象。
人格分裂 当测试显现出人格分裂时,它认为它本身体现了多个测试。那是不对的。一个测试应当仅检查一件事并妥善执行。
下面的测试类针对一些命令行接口。它其实是用不同的命令行参数来测试Configuration对象的行为。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class TestConfiguration { @Test public void testParsingCommandLineArguments () { String[] args = { "-f" , "hello.txt" , "-v" , "--version" }; Configuration c = new Configuration (); c.processArguments(args); assertEquals("hello.txt" , c.getFileName()); assertFalse(c.isDebuggingEnabled()); assertFalse(c.isWarningsEnabled()); assertTrue(c.isVerbose()); assertTrue(c.shouldShowVersion()); c = new Configuration (); try { c.processArguments(new String [] {"-f" }); fail("Should've failed" ); } catch (InvalidArgumentException expected) { } } }
该测试用例的多重人格体现在它涉及了文件名,调试、警告、信息开关、版本号显示,还处理了空的命令行参数列表。注意这个测试没有遵循安排-执行-断言 (arrange-act-assert)结构。
解法:
单元测试代码的可维护性 重复 重复是代码的万恶之源。
重复是存在多份拷贝,或对单一概念的多次表达——这都是不必要的重复。
1 2 3 4 5 6 7 8 9 10 public class TemplateTest { @Test public void emptyTemplate () throws Exception { assertEquals("" , new Template ("" ).evaluate()); } @Test public void plainTextTemplate () throws Exception { assertEquals("plaintext" , new Template ("plaintext" ).evaluate()); } }
是最常见的文本字符串重复,在两个断言中,空串和plaintest字符串都出现了两次,我们叫这种重复为文字重复 (Literal Duplication)。你可以通过定义局部变量来移除它们。在上面的测试类中还存在另一种重复,也许比显而易见的字符串重复有趣得多。当我们提取那些局部变量时,这种重复会变得更加清晰。
1 2 3 4 5 6 7 8 9 10 11 12 public class TemplateTest { @Test public void emptyTemplate () throws Exception { String template = "" ; assertEquals(template, new Template (template).evaluate()); } @Test public void plainTextTemplate () throws Exception { String template = "plaintext" ; assertEquals(template, new Template (template).evaluate()); } }
你会发现在那两个测试函数中,只有字符串是不同的。将那些不同的部分提取为局部变量以后,剩下的断言何止是相似,简直一模一样。它们都实例化了一个Template对象,调用了evaluate函数对模板求值,并断言此函数返回相应的字符串。这种操作不同数据的重复逻辑,我们叫做结构重复 (Structural Duplication)。以上的两个代码块用一致的结构操作了不同的数据。让我们去掉这种重复,并看看代码会变成什么样。
1 2 3 4 5 6 7 8 9 10 11 12 public class TemplateTest { @Test public void emptyTemplate () throws Exception { assertTemplateRendersAsItself("" ); } @Test public void plainTextTemplate () throws Exception { assertTemplateRendersAsItself("plaintext" ); } private void assertTemplateRendersAsItself (String template) { assertEquals(template, new Template (template).evaluate()); } }
文字重复或结构重复,长得一样,很容易看出来,可以通过提炼相似的结构来清晰地识别文字重复 (Literal Duplication) 和结构重复 (Structural Duplication),但语义重复 (Semantic Duplication) ——用看起来并不相同的表达和实现完成相同功能或概念——很难看出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Test public void groupShouldContainTwoSupervisors () { List<Employee> all = group.list(); List<Employee> employees = new ArrayList <Employee>(all); Iterator<Employee> i = employees.iterator(); while (i.hasNext()) { Employee employee = i.next(); if (!employee.isSupervisor()) { i.remove(); } } assertEquals(2 , employees.size()); } @Test public void groupShouldContainFiveNewcomers () { List<Employee> newcomers = new ArrayList <Employee>(); for (Employee employee : group.list()) { DateTime oneYearAgo = DateTime.now().minusYears(1 ); if (employee.startingDate().isAfter(oneYearAgo)) { newcomers.add(employee); } } assertEquals(5 , newcomers.size()); }
除了通过group.list()方法查询雇员的完整列表之外,这两个方法没有太多的文字重复,但是它们又很相似,都是查询雇员的完整列表并通过某些条件进行过滤。
解法:
第一步,通过改变过滤方法,使之成为结构重复。第二步,再通过提炼变量和方法,消除结构重复。
条件逻辑 条件执行语句使代码难以理解。你应该在测试方法中避免条件执行结构,例如if、else、for、 while和switch等。当测试要检查的行为比较复杂时,这一点尤其重要。这些语言结构固然有用,但对于测试代码,你应该小心行事。为了测试的可维护性,你需要保持测试的复杂度低于对应的解决方案。
以下测试用例创建了Dictionary(字典)对象,用数据填充它,并验证请求到的Iterator (迭代器)中内容是正确的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class DictionaryTest { @Test public void returnsAnIteratorForContents () throws Exception { Dictionary dict = new Dictionary (); dict.add("A" , new Long (3 )); dict.add("B" , "21" ); for (Iterator e = dict.iterator(); e.hasNext();) { Map.Entry entry = (Map.Entry) e.next(); if ("A" .equals(entry.getKey())) { assertEquals(3L , entry.getValue()); } if ("B" .equals(entry.getKey())) { assertEquals("21" , entry.getValue()); } } } }
测试实际上期望能在Iterator中找到填充的那两个条目。在修改测试时,比方说通过重构来改变Dictionary类的API,我们的目标是使意图明确和减少出错几率。对于测试代码中的全部条件执行语句,我们很容易认为某个断言已经通过但实际上却没有运行过。这是我们要避免的。
解法:
这种情况在处理过度复杂的代码时经常遇到,第一步就是简化它,通常是将一段代码提炼到独立且命名良好的方法中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Test public void returnsAnIteratorForContents () throws Exception { Dictionary dict = new Dictionary (); dict.add("A" , new Long (3 )); dict.add("B" , "21" ); assertContains(dict.iterator(), "A" , 3L ); assertContains(dict.iterator(), "B" , "21" ); } private void assertContains (Iterator i, Object key, Object value) { while (i.hasNext()) { Map.Entry entry = (Map.Entry) i.next(); if (key.equals(entry.getKey())) { assertEquals(value, entry.getValue()); return ; } } fail("Iterator didn't contain " + key + " => " + value); }
提炼出的方法中仍然存在循环,但是测试的意图变得清晰,使得可维护性大不相同了。
而且还更正了原先测试中的另一个问题:即使Iterator是空的,测试也不会失败。我们是这样修改的,当找到预期条目就使断言返回,而如果没找到符合的条目就使测试失败。最后的fail()调用很容易被漏掉,导致测试虽然通过但是代码却不工作,令人头痛。长点儿心吧!
脆弱的测试 修改代码导致了测试失败——那是件好事情,测试显然注意到了你认为重要而需要检查的变更——这不同于一直失败的测试。我们说的是一直亮了两周红灯的测试,而且没人去修复它。那个测试大概会继续再失败两周或者两个月,直到有人修复它,或者更有可能的是,删掉它。这都在情理之中,因为一直失败的测试几乎是没用的。你无法从它那里得知任何你还不知道的事情,或者可以信任的事情。
还有一种失败的测试带给我们的生活不必要的麻烦:断断续续失败的脆弱测试 。脆弱的意思就像喊“狼来了” ,也就是说,虽然测试使构建失败了,但是你去看的时候,再次执行却又通过了,刚才似乎只是个意外。
大多数情况下,脆弱测试失败时都涉及了线程和竞态条件,其行为依赖于日期或时间,或测试依赖于计算机的性能,因而在运行期间受到I/O速度或CPU负载的影响。访问网络资源的测试有时也会表现得脆弱,比如当网络或资源临时不可用的时候。例子展示了一个我不止一次碰到的问题:对文件系统时间戳的错误假设。
1 2 3 4 5 6 7 8 9 10 @Test public void logsAreOrderedByTimestamp () throws Exception { generateLogFile(logDir, "app-2.log" , "one" ); generateLogFile(logDir, "app-1.log" , "two" ); generateLogFile(logDir, "app-0.log" , "three" ); generateLogFile(logDir, "app.log" , "four" ); Log aggregate = AggregateLogs.collectAll(logDir); assertEquals(asList("one" , "two" , "three" , "four" ), aggregate.asList()); }
问题在于,被测逻辑依赖于文件的时间戳。我们期望日志文件按照时间戳顺序进行汇总,将最早修改的文件作为最旧的日志文件,以此类推,并没有按照文件名的字母顺序对日志文件进行排序。结果让我们失望了。这个测试在Linux上时不时地失败,而在Mac OS X上几乎总是失败。我们看看测试中用来写日志文件的generateLogFile()方法:
1 2 3 4 5 6 7 private void generateLogFile (final File dir, final String name, final String... messages) { File file = new File (dir, name); for (String message : messages) { IO.appendToFile(file, message); } }
我们栽在了忘记给创建日志文件的被测方法传递时间。具体来说,我们忘记了在不同的计算机和平台上时间流逝得不一样。某些平台,比如Mac OS X,文件系统时间戳每秒跳动一次,也就是说除非文件生成时间足够长,或者文件生成地恰到好处,否则下一次generateLogFile()调用极有可能导致日志文件带有相同的时间戳。
当你的测试遇到时间相关的问题时,你应该先通过适当的API来模拟相应的情况。对于用正确时间戳来产生日志文件这个例子,你可以依靠Java的File API,用setLastModified明确地递增每个日志文件的时间戳。清单5.7显示了修改后的工具方法。
1 2 3 4 5 6 7 8 private AtomicLong timestamp = new AtomicLong (currentTimeMillis());private void generateLogFile (final File dir, final String name, final String... messages) { File file = new File (dir, name); for (String message : messages) { IO.appendToFile(file, message); } file.setLastModified(timestamp.addAndGet(TEN_SECONDS)); }
这样我们就有效地确保每个日志文件都收到了不同的时间戳。测试能够可靠地工作于所有平台和计算机,而不需要再缓慢地执行测试了。
对于间歇性的测试失败,时间戳并非唯一的麻烦来源。最突出的是多线程,它往往会使事情复杂化。涉及随机数生成的也好不到哪儿去。无论我们是否提早意识到要针对随机测试失败来保护自己,我们解决这些情况的方法都是一样的:
规避它。
控制它。
隔离它。
最简单的办法就是彻底地规避这个问题,切断任何不确定行为的来源。例如,我们可以通过文件名的数字后缀而非时间戳来对日志文件排序。如果不能轻易地绕过棘手的部分,我们就试图控制它。例如,可以采用Fake(伪造对象)来代替随机数生成器,令其精确地返回我们需要的值。最后,如果找不到一个方法来充分地规避或控制这个问题的源头,我们的最后一招就是将难题隔离在尽可能小的范围内。这就将大多数代码从不确定行为中解脱出来,从而在一处而且仅仅一处来解决难题。
残缺的文件路径 残缺的文件路径 会使你无法转移代码,除了自己的电脑之外,在任何其他人的电脑上都无法运行。我们都知道这个问题。假如你从没读过代码,却天生就懂得说“我们不应该在这里硬编码这个路径”,或者天生就知道明确地引用文件系统某个位置会自寻烦恼,那就怪了。下面的测试用例,它采用绝对路径从文件系统读出一份文档。
1 2 3 4 5 6 7 8 9 10 11 12 13 public class XmlProductCatalogTest { private ProductCatalog catalog; @Before public void createProductCatalog () throws Exception { File catalogFile = new File ("C:\\workspace\\catalog.xml" ); catalog = new XmlProductCatalog (parseXmlFrom(catalogFile)); } @Test public void countsNumberOfProducts () throws Exception { assertEquals(2 , catalog.numberOfProducts()); } }
文件路径有什么问题?首先,绝对文件路径明显地绑定了Microsoft Windows操作系统。开发者要是在Mac或Linux系统上运行测试的话,马上会得到“找不到文件(file not found)”之类的I/O错误。这段测试代码不是自包含的(self-contained)。你需要从版本控制系统中检出代码,然后马上就看到所有测试都通过。用了这种特定于平台的绝对文件路径的话,想都别想了。
解法:
我们现在有一个仅限于Windows的绝对文件路径。它不能在任何Mac或Linux系统上工作。除非我们全都是Windows用户,否则我们至少应该将路径改为与平台无关,比如Java的File API支持这样做:
1 new File ("/workspace/catalog.xml" );
在Windows和UNIX 两个平台家族中,它都能 胜任,比如你使用Windows上的C盘,那么路径就是C:\workspace
,而在UNIX系统上路径就是/workspace
。我们仍然被绑定在workspace这个特定位置上。这不像绑定特定用户名那么糟糕(比如:/User/lasse
),但其实仍会强迫所有开发者都将自己的工作区(workspace)放在相同的物理位置上。
尽量采用抽象引用,比如系统属性(system properties)、环境变量,或任何能获得的相对路径。不建议像System.getProperties(“user.home”)这样来选择相对路径,虽然在所有平台上都会返回一个有效的路径,但是那些引用里面仍然是绝对路径,仍会强迫开发者们将其工作区放在指定位置。尤其当你开发新版本同时还要维护旧版本,这种情况就非常棘手。每当你从新开发工作切换到旧分支改Bug时,你不得不在未完成工作的基础上检出旧分支,因为绝对路径永远指向同一个位置。总是尽可能去引用相对路径,例如:
1 new File ("./src/test/data/catalog.xml" );
永久的临时文件 临时文件应该是暂时性的,使用后即抛弃和删除。永久的临时文件 这个测试坏味道,指的是临时文件并非暂存而是被保留了下来,也就是说在下个测试运行前它都不会被删除。
俗话说,“YY是失败之母”,而程序员往往就假设临时文件是暂时性的。这会带来一些意外,而那正是我们试图避免去调试的行为。事实上,你应该尝试根本不使用文件,因为它根本不是你要测试的对象。
想象针对一个基于文件的产品登记册(Product Catalog)对象的一系列测试。第一个测试会创建一个空的临时文件,不包含任何条目,然后用空文件初始化登记册,并检查登记册如期为空。第二个测试不创建文件,而是基于一个缺失的文件来初始化登记册,登记册这时应该表现得就好像那个文件存在似的,但只是个空文件。第三个测试又创建一个临时文件——这次包含了两个产品——用该文件来初始化登记册,然后检查登记册确实是包含了那两个产品。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class XmlProductCatalogTest { private File tempfile; @Before public void prepareTemporaryFile () { String tmpDir = System.getProperty("java.io.tmpdir" ); tempfile = new File (tmpdir, "catalog.xml" ); } @Test public void initializedWithEmptyCatalog () throws Exception { populateCatalogWithProducts(0 ); ProductCatalog catalog = new XmlProductCatalog (tempfile); assertEquals(0 , catalog.numberOfProducts()); } @Test public void initializedWithMissingCatalog () throws Exception { ProductCatalog catalog = new XmlProductCatalog (tempfile); assertEquals(0 , catalog.numberOfProducts()); } @Test public void initializedWithPopulatedCatalog () throws Exception { populateCatalogWithProducts(2 ); ProductCatalog catalog = new XmlProductCatalog (tempfile); assertEquals(2 , catalog.numberOfProducts()); } }
当JUnit开始执行这个测试类时,会在每个测试之前用新的File 对象来初始化私有字段tempfile。没问题。问题在于第二个测试,当 initializedWithMissingCatalog 开始执行时,系统的临时目录中已经存在一个空的catalog.xml 文件,这与测试的假设大相径庭。这意味着无论XmlProductCatalog 是否正确地处理了文件缺失,测试都能通过。不妙。
解法:
一般来说,能不用物理文件就尽量不用;比起操作字符串或其它类型的内存块来说,文件I/O 会使得测试变慢。说到这,当你处理测试中的临时文件时,你需要记住临时文件并不是随时保持临时性的。例如,如果你用File#createTempFile
API获得一个临时文件,文件就和你手中的其它文件引用一样,不再是暂时性的了。除非你明确地删除它,否则文件在测试结束后哪也不会去。相反,它就是一个待在原地的幽灵文件,有可能造成看似不稳定的测试失败问题。
于是针对临时文件的处理,我们归纳出几条简单的指导方针:
在@Before中删除文件。
如果可能的话,使用唯一的临时文件名。
要弄清楚文件是否应该存在。
关于第一条,程序员有时会用 File#deleteOnExit 将临时文件标记为删除,然后快乐地继续工作。那样做是不够的,因为文件在JVM进程退出之前并没有真正地被删除。相反,你需要在每个测试方法使用文件之前 就删除它们,咱们就给setup增加一条语句:
1 2 3 4 5 6 @Before public void prepareTemporaryFile () { String tmpDir = System.getProperty("java.io.tmpdir" ); tempfile = new File (tmpdir, "catalog.xml" ); tempfile.delete(); }
其次,当文件名和位置不需要那么精确的时候,推荐使用File#createTempFile。你能够确保在每次测试时得到唯一的文件名,这样你就不必担心从另一个测试继承来一个幽灵文件。本例中setup方法将修改如下:
1 2 3 4 @Before public void prepareTemporaryFile () { tempfile = File.createTempFile("catalog" , ".xml" ); }
再者,当你不关心文件系统中是否存在物理文件时,默不作声即可。但当你在意时,你就应该明确表达出来。
1 2 3 4 5 6 7 8 9 @Test public void initializedWithMissingCatalog () throws Exception { withMissingFile(tempfile); ProductCatalog catalog = new XmlProductCatalog (tempfile); assertEquals(0 , catalog.numberOfProducts()); } private void withMissingFile (File file) { file.delete(); }
明确对执行环境的期望,除了能确保缺失的文件不存在以外,还有助于表达测试的意图。
沉睡的蜗牛 缓慢的测试是影响可维护性的主要障碍,因为不论是添加新功能还是修复问题,程序员在修改代码时都需要反复地运行测试。文件I/O并非缓慢测试的唯一来源。通常对测试套件执行时间带来的更大冲击是源自大量的Thread#sleep 调用,它允许其他线程先完成工作,然后再对预期结果和副作用进行断言。沉睡的蜗牛非常容易发现:查找Thread#sleep调用以及留意异常缓慢的测试。
例子展示了对Counter对象的测试,该对象负责产生唯一的数字。显然,即使多个线程同时调用Counter,我们也希望这些数字是独一无二的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Test public void concurrentAccessFromMultipleThreads () throws Exception { final Counter counter = new Counter (); final int callsPerThread = 100 ; final Set<Long> values = new HashSet <Long>(); Runnable runnable = new Runnable () { @Override public void run () { for (int i = 0 ; i < callsPerThread; i++) { values.add(counter.getAndIncrement()); } } }; int threads = 10 ; for (inti=0 ;i<threads;i++){ new Thread (runnable).start(); } Thread.sleep(500 ); int expectedNoOfValues = threads * callsPerThread; assertEquals(expectedNoOfValues, values.size()); }
测试所做的工作,先是建立几个将要重复地访问计数器的线程,然后启动所有线程。由于测试无法预知线程是如何被调度的,以及何时会结束,于是我们调用了Thread#sleep,令测试在每次断言之前先等待半秒钟。
尽管500毫秒听起来有点夸张,但其实并不是的。就算睡上(sleep)300毫秒,在我的电脑上测试大约也会有10%的几率失败——那正是我们讲过的脆弱的测试 !现在,500毫秒的sleep仍然不可靠;有时需要更久的时间才能让全部线程完成工作。一旦是因为500毫秒的sleep还不够久而导致了测试失败,程序员就会延长sleep。毕竟,我们不喜欢脆弱的测试。还有另一个问题:它相当地慢。 尽管500毫秒听起来不多,但是积少成多啊,尤其是当测试代码中遍布着许多Thread#sleep的时候。
解法:
替换掉了奢侈和不可靠的Thread#sleep。最根本的不同在于我们使用了java.util.concurrent包中的同步对象,使得测试能够立即知晓工作线程的任务结束了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Test public void concurrentAccessFromMultipleThreads () throws Exception { final Counter counter = new Counter (); final int numberOfThreads = 10 ; final CountDownLatch allThreadsComplete = new CountDownLatch (numberOfThreads); final int callsPerThread = 100 ; final Set<Long> values = new HashSet <Long>(); Runnable runnable = new Runnable () { @Override public void run () { for (int i = 0 ; i < callsPerThread; i++) { values.add(counter.getAndIncrement()); } allThreadsComplete.countDown(); } }; for (int i = 0 ; i < numberOfThreads; i++) { new Thread (runnable).start(); } allThreadsComplete.await(10 , TimeUnit.SECONDS); int expectedNoOfValues = numberOfThreads * callsPerThread; assertEquals(expectedNoOfValues, values.size()); }
该方案基于一个简单的想法,即每个工作线程结束后应当通知测试线程,从而解决了这个坏味道。只要测试线程得到这个信息,测试就可以立即继续自己的工作,基本上避免了随意添加Thread#sleep带来的不必要等待。方案建立在Java API中的标准同步对象CountDownLatch
上。CountDownLatch有效地确保仅当指定的线程数量触发了锁才能执行测试。如果你的系统要处理线程,那么给自己一次机会,投入工具类java.util.concurrent
包的怀抱吧。
像素级完美 像素完美 是基本断言和魔法数字的特例。把它放在这个坏味道分类中,是因为我觉得它在计算机绘图领域频繁出现,那里的人们经常会说在测试中遇到困难。坏味道出现的场景包括,测试要求期望和实际产生的图像完美地匹配,或者期望在产生的图像中,指定坐标处的像素包含某种颜色。这样的测试是出了名的脆弱,通常很小的输入变化都会导致测试失败。
例子允许用户操作屏幕上的图表,在画布上拖放长方形并用各种线段连接起来。对象图所产生的图表,其中包含各种长方形和线段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class RenderTest { @Test public void boxesAreConnectedWithALine () throws Exception { Box box1 = new Box (20 , 20 ); Box box2 = new Box (20 , 20 ); box1.connectTo(box2); Diagram diagram = new Diagram (); diagram.add(box1, new Point (10 , 10 )); diagram.add(box2, new Point (40 , 20 )); BufferedImage image = render(diagram); assertThat(colorAt(image, 19 , 12 ), is(WHITE)); assertThat(colorAt(image, 19 , 13 ), is(WHITE)); assertThat(colorAt(image, 20 , 13 ), is(BLACK)); assertThat(colorAt(image, 21 , 13 ), is(BLACK)); assertThat(colorAt(image, 22 , 14 ), is(BLACK)); assertThat(colorAt(image, 23 , 14 ), is(BLACK)); assertThat(colorAt(image, 24 , 15 ), is(BLACK)); assertThat(colorAt(image, 25 , 15 ), is(BLACK)); assertThat(colorAt(image, 26 , 15 ), is(BLACK)); assertThat(colorAt(image, 27 , 16 ), is(BLACK)); assertThat(colorAt(image, 28 , 16 ), is(BLACK)); assertThat(colorAt(image, 29 , 17 ), is(BLACK)); assertThat(colorAt(image, 30 , 17 ), is(BLACK)); assertThat(colorAt(image, 31 , 17 ), is(WHITE)); assertThat(colorAt(image, 31 , 18 ), is(WHITE)); } }
首先,测试难以验证。我是说,我怎么知道**(23, 14)处应该有一个黑色像素?此外,不难看出这个测试是多么容易打破。任何图表边界宽度的变化,例如线段的精确位置稍有偏移,测试就会打破。这是不好的,因为测试检查的是图表中两个相连的长方形是否也可见地连在一起,而长方形的 样子**不该影响测试。
如果我们测试长方形的总渲染宽度,我们可以用魔法数字来对付这个问题,用类似boxContentWidth + (2 * boxBorderWidth)的变量组成断言,来包容边界宽度的变化。但是对于两个长方形之间的线段,我们的做法没有那么显而易见。
解法:
用适当的抽象层次来表达测试,这是编写它们的目标之一。如果你对两个长方形相互连接感兴趣,你不该考虑像素或坐标。相反,你应该考虑长方形 ,以及它们是否相连 。简单吗?这就是说,不要用基本断言 ,而是应该将背后的细枝末节隐藏到自定义断言中,那里才是适合的抽象层次。同时也意味着,相对于精确的值与值比较,断言中反而需要进行模糊 匹配——一种实际的算法。
将检查两个长方形是否连接的细节委托给自定义断言。自定义断言会操心如何检查(最好是以灵活和健壮的方式)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class RenderTest { private Diagram diagram; @Test public void boxesAreConnectedWithALine () throws Exception { Box box1 = new Box (20 , 20 ); Box box2 = new Box (20 , 20 ); box1.connectTo(box2); diagram = new Diagram (); diagram.add(box1, new Point (10 , 10 )); diagram.add(box2, new Point (40 , 20 )); assertThat(render(diagram), hasConnectingLineBetween(box1, box2)); } private Matcher<BufferedImage> hasConnectingLineBetween ( final Box box1, final Box box2) { return new BoxConnectorMatcher (diagram, box1, box2); } }
主要区别在于,将大量看似任意坐标上的像素颜色测试替换掉,代之以口语化的自定义断言——在两个长方形之间应该有一条连接线段。可读性变得好多了,而且这个特殊的测试需要的维护工作更少。然而,复杂性没有完全消除掉。我们仍然需要告诉计算机如何辨别两个长方形是否相连,而那正是神秘的BoxConnectorMatcher的职责。BoxConnectorMatcher的实现细节看似与要介绍的这种测试坏味道没啥关系。毕竟,这是一个特殊情况的方案。但是因为许多程序员似乎都对图形相关代码的可测性失去了信心,因此我觉得仔细看看其实现还是有必要的。
接下来,用自定义的匹配器来检查两个长方形之间的连接。即使长方形的相关位置变动了一个像素,这种智能断言也不会被打破。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class BoxConnectorMatcher extends BaseMatcher <BufferedImage> { private final Diagram diagram; private final Box box1; private final Box box2; BoxConnectorMatcher(Diagram diagram, Box box1, Box box2) { this .diagram = diagram; this .box1 = box1; this .box2 = box2; } @Override public boolean matches (Object o) { BufferedImage image = (BufferedImage) o; Point start = findEdgePointFor(box1); Point end = findEdgePointFor(box2); return new PathAlgorithm (image).areConnected(start, end); } private Point findEdgePointFor (final Box box1) { Point a = diagram.positionOf(box1); int x = a.x + (box1.width() / 2 ); int y = a.y - (box1.height() / 2 ); return new Point (x, y); } @Override public void describeTo (Description d) { d.appendText("connecting line exists between " + box1 + " and " + box2); } }
BoxConnectorMatcher的精华都在matches()方法中,首先分别找出两个长方形的任意一个边缘点,然后委托给PathAlgorithm (路径算法)对象,来判定两个坐标是否在图中相连。
接下来是PathAlgorithm的实现。警告:这是一段冗长的代码,我不打算细致地解释。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class PathAlgorithm { private final BufferedImage img; private Set<Point> visited; private int lineColor; public PathAlgorithm (BufferedImage image) { this .img = image; } public boolean areConnected (Point start, Point end) { visited = new HashSet <Point>(); lineColor = img.getRGB(start.x, start.y); return areSomehowConnected(start, end); } private boolean areSomehowConnected (Point start, Point end) { visited.add(start); if (areDirectlyConnected(start, end)) return true ; for (Point next : unvisitedNeighborsOf(start)) { if (areSomehowConnected(next, end)) return true ; } return false ; } private List<Point> unvisitedNeighborsOf (Point p) { List<Point> neighbors = new ArrayList <Point>(); for (int xDiff = -1 ; xDiff <= 1 ; xDiff++) { for (int yDiff = -1 ; yDiff <= 1 ; yDiff++) { Point neighbor = new Point (p.x + xDiff, p.y + yDiff); if (!isWithinImageBoundary(neighbor)) continue ; int pixel = img.getRGB(neighbor.x, neighbor.y); if (pixel == lineColor && !visited.contains(neighbor)) { neighbors.add(neighbor); } } } return neighbors; } private boolean isWithinImageBoundary (Point p) { if (p.x < 0 || p.y < 0 ) return false ; if (p.x >= img.getWidth()) return false ; if (p.y >= img.getHeight()) return false ; return true ; } private boolean areDirectlyConnected (Point start, Point end) { int xDistance = abs(start.x - end.x); int yDistance = abs(start.y - end.y); return xDistance <= 1 && yDistance <= 1 ; } }
PathAlgorithm的实现其实就是深度优先搜索,从起始坐标开始,遍历每个颜色符合的相邻坐标,直到走完所有相连的点,或者遇到与终止坐标直接相邻的点。
这种测试极其脆弱,因为即使很小的和不相关的输入变化——是否是另一个图像,或图形对象的渲染方式——都足以影响输出、打破测试,谁让你非要精确地检查像素坐标和颜色来着。同样的问题在采用golden master (捕获并保存当前的处理结果,未来的运行结果将会与保存的结果进行对比,从而发现预期之外的变更。又称Approve Test或标杆比对测试法)技术时也会遇到,其做法是事先将图像录制下来,并手工检查其正确性,以后再进行测试时就将渲染出的图像与之进行比对。
在上述方案中,我们可以在适当抽象层次上针对表达意图的图像输出来编写断言——针对高层概念而非像素坐标。但你也看到了,这种自定义断言实现起来并不容易。无论如何,考虑到像素完美的极度脆弱性时,这样做还是物有所值的。
参数化的混乱 先看参数化测试模式的典型起始点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class RomanNumeralsTest { @Test public void one () { assertEquals("I" , format(1 )); } @Test public void two () { assertEquals("II" , format(2 )); } @Test public void four () { assertEquals("IV" , format(4 )); } @Test public void five () { assertEquals("V" , format(5 )); } @Test public void seven () { assertEquals("VII" , format(7 )); } @Test public void nine () { assertEquals("IX" , format(9 )); } @Test public void ten () { assertEquals("X" , format(10 )); } }
包含一堆只有一行的测试方法,仅仅是输入和期望输出值有所不同。被测代码是个工具类,负责将数字格式化为罗马数字。这种情形正是参数化测试模式的用武之地,其降低代码行数的手段主要是去除样板代码(boilerplate)。使用参数化测试模式和JUnit的Parameterized test runner(测试运行器)重写了相同的测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @RunWith(Parameterized.class) public class RomanNumeralsTest { private int number; private String numeral; public RomanNumeralsTest (int number, String numeral) { this .number = number; this .numeral = numeral; } @Parameters public static Collection<Object[]> data() { return asList(new Object [][] { { 1 , "I" }, { 2 , "II" }, { 4 , "IV" }, { 5 , "V" }, { 7 , "VII" }, { 9 , "IX" }, { 10 , "X" } }); } @Test public void formatsPositiveIntegers () { assertEquals(dataset(), numeral, format(number)); } private String dataset () { StringBuilder s = new StringBuilder (); s.append("Data set: [" ).append(this .number); s.append(", \"" ).append(this .numeral).append("\"]." ); return s.toString(); } }
可以用类似@Parameters 方法中的方式来组装一个失败消息,用于描述整个数据集,使得JUnit在匿名测试失败时能表达出该信息。
方法间缺乏内聚 内聚是构造良好的面向对象代码的主要属性。简而言之,内聚意味着一个类只代表单一事物 ,单一抽象。内聚是好事——我们希望高内聚——而内聚 也是一种代码坏味道。本节标题中的“方法间 ”指的是,我们通过观察一个类中各方法之间的共性来确定内聚度。尽管有各种手段来计算方法内聚度,但常见公式的分母都是基于一个大概的想法,即完美内聚意味着一个类的每个字段都被每个方法所使用到。
代码是处理**拆分会计 (split accounting)**的,用于将某条目的成本分散到多个会计组中。例如,新的数据库服务器将被三个不同部门使用,因此你可以将其价格分拆到三个不同的预算中。代码用三个领域对象来处理拆分会计操作:Account、Split和BudgetCategory。操作本身用Transaction对象来表示。清单中的测试类在@Before方法中为这些领域对象初始化一个夹具(fixture),然后测试类有几个测试方法工作在那些对象上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class SplitsTest { Account account; Split split; BudgetCategory bc1, bc2, bc3, bc4; @Test public void fromSplits () throws Exception { List<TransactionSplit> fromSplits = new ArrayList <TransactionSplit>(); fromSplits.add(createSplit(bc3, 1200 )); fromSplits.add(createSplit(bc4, 34 )); Transaction t = createTransaction(split, account); t.setFromSplits(fromSplits); assertTrue(transactions(t).size() == 1 ); } @Test public void toSplits () throws Exception { List<TransactionSplit> toSplits = new ArrayList <TransactionSplit>(); toSplits.add(createSplit(bc1, 1200 )); toSplits.add(createSplit(bc2, 34 )); Transaction t = createTransaction(account, split); t.setToSplits(toSplits); assertTrue(transactions(t).size() == 1 ); } }
坏味道是由于测试类的夹具包含了多个BudgetCategory 对象,然后测试方法只用到其中一部分。这明显与每个测试都工作在相同夹具上的想法背道而驰;各个测试方法缺乏内聚。从更大角度来看,一个过度复杂的夹具会使程序员更难理解发生了什么。更多的字段意味着更多变化的事物充斥着你的大脑。单看其中一个测试,其实也很难找出它用到了哪些字段,因为不够明显。我们反而留下疑问,“哪个category也是bc3来着?”这个问题源于一个基本的代码坏味道:糟糕的命名。尽管测试方法缺乏内聚并不会令你完全不能对夹具进行良好地命名,但往往会相当困难。
例如,每个测试都具有一个输入和一个输出,但由于每个测试都有这么一对儿对象,你就不得不按照emptyInput、 inputWithOneThing、inputWithThreeIdenticalThings等来命名。这样,你就不能简单地看着夹具说,“没错。这两个是一起的,而剩下的与这个测试无关。”
解法:
我们已经拿出了一个对策——将类分拆。此外,更好的典型方案是强制各测试使用相同的夹具对象。要将一个测试类分解为两个,我们需要识别哪部分夹具是所有测试共享的,哪些是个别测试独有的。
在该类其余的测试中,没有哪个用到两个以上的BudgetCategory 对象,而且bc1和bc2总是一起出现,要么bc3和bc4一起出现。你应该寻找这类群组,找出自然边界从而将类分拆成两个或三个更小、更专注的——内聚的——测试类。
有时你需要的就是创建另一个测试类,将某些方法和夹具对象搬移过去,然后把手擦干净。通常,测试依赖于相同的工具方法,而我们不喜欢重复。这时,我们也需要将那些工具方法移到单独的类中,如此一来,分拆后的测试类都可以使用它,或者提炼一个公共基类来提供这些工具。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class TestSplitsAcrossDifferentBudgetCategories extends AbstractSplitsTestCase { private BudgetCategory incomeCategory, standardCategory; @Before public void setup () throws Exception { incomeCategory = new BudgetCategoryImpl (); incomeCategory.setIncome(true ); standardCategory = new BudgetCategoryImpl (); standardCategory.setIncome(false ); } @Override protected List<BudgetCategory> budgetCategories () { return asList(incomeCategory, standardCategory); } }
现在夹具变得多么简单。这个测试类中用到了哪些budget category以及相互之间的区别都一目了然。将所有公共部分搬移到抽象基类AbstractSplitsTestCase中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public abstract class AbstractSplitsTestCase { protected Account account; @Before public void initializeAccount () throws Exception { ... } protected abstract List<BudgetCategory> budgetCategories () ; protected List<Transaction> transactions (Transaction t) throws ModelException { Document d = ModelFactory.createDocument(); for (BudgetCategory bc : budgetCategories()) { d.addBudgetCategory(bc); } d.addAccount(account); d.addTransaction(t); return d.getTransactions(); } protected TransactionSplit createSplit (BudgetCategory category, int amount) throws InvalidValueException { ... } protected Transaction createTransaction (Source from, Source to) throws InvalidValueException { ... } }
单元测试代码的可信赖性 注释掉的测试 测试代码被注释掉了时,却不知原因,令人感到困惑。测试的@Test注解注释掉时,也会产生同样的问题。代码编译通过,并且看起来像个测试,但是JUnit不会将其当成测试来运行,因为注解不见了。
当你发现测试(或其一部分)被注释掉了,你其实看到的是死代码。或许它曾经具有意义和目的,但如今要么不存在了,要么丢失了,你无法想象注释掉的代码为什么会在那儿。
解法:
尝试理解并验证它的目的。如果你找到了,取消注释并重构测试,这样它能更好地沟通意图。
否则,删掉它。
有歧义的注释 测试用例包含了几个步骤,正如那些仅存的单行注释和用来分割代码块的空行所示。第一段我们创建一个基本账户,然后向账户注册一笔债务,然后验证账户的信誉随着时间超过截止日期而由好变差。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Test public void pastDueDateDebtFlagsAccountNotInGoodStanding () { Customer customer = new Customer (); DeliquencyPlan deliquencyPlan = DeliquencyPlan.MONTHLY; Account account = new CorporateAccount (customer, deliquencyPlan); Money amount = new Money ("EUR" , 1000 ); account.add(new Liability (customer, amount, Time.fromNow(1 , DAYS))); assertTrue(account.inGoodStanding()); Time.moveForward(1 , DAYS); assertFalse(account.inGoodStanding()); }
简单吗?是的。除了两个注释是误导的,很可能对于测试的实际行为给出了错误的印象。
首先,第一段代码据说创建了一个“基本”账户,但代码实际上却用特定的拖欠计划创建了一个企业账户 (CorporateAccount
)。那不是我所认为的“基本”。其次,尽管我们实际上将时间快进到截止日期当天,后一个注释却说我们将时间模拟为“超过截止日期”。通常,处于 当天与超过 截止日期是不同的。你注意到了那个细节吗?
解法:
将注释替换为更好的变量和方法名。
从被注释的代码段中抽取一个方法,并妥善命名。
这种注释——描述代码行为——的基本问题在于,代码实际上应当负责沟通自己的意图。如果你感觉你需要一个注释来解释代码行为,你仍有重构的余地。或许你没有妥善地对变量命名。或许代码段应当封装在一个具有描述性名字的私有方法内。
怎样写好注释?简言之,好的代码注释解释为什么 ,而非做什么 。
每当你碰到一个解释代码行为的注释,那就是代码坏味道。代码应当足够可读,从而没有注释的必要。有一些情况下,真的需要注释。在那种情况下,注释解释着某个代码块的缘由 。
例如,一个注释在解释复杂for循环的行为,它就意味着坏味道,而另一个类似的for循环却可能具有良好有效的注释,它在解释循环有多丑陋,因为那是一段性能上至关重要的代码,你还没有找到一个在不牺牲性能的前提下改进可读性的方式。
每当你发现自己在写注释,问问自己到底在描述“做什么”还是“为什么”。然后重新考虑你是否该写注释,还是重构被注释的代码。
有时程序员在方法中添加注释,以看得见的方式使得代码段互相分离。这个考虑可以有,因为最终目的还是为了使代码更加容易阅读。但这种方式值得商榷。
这种情况下,我通常建议用空行来替换注释——至少空行不会像注释那样有时会腐坏。我的一个同事习惯于从方法中删除所有空行,因为那些是代码坏味道。如果你需要用空白字符来区分步骤的话,就表明那个方法太大了。尽管那听起来有点极端,但他是有道理的。那个方法可能太大了,你真的应该考虑重构,可能会是抽取一至两个方法。
永不失败的测试用例 不能失败的测试不具有价值,出了事情它决不警告你。永不失败的测试比没有测试还糟,因为它给你虚假的安全感。
检查是否抛出期望的异常,或许这是最常见的存在永不失败的测试的上下文。另一种常见的永不失败的测试就是没有断言。
1 2 3 4 5 6 7 8 9 @Test public void includeForMissingResourceFails () { try { new Environment ().include("something that does not exist" ); } catch (IOException e) { assertThat(e.getMessage(), contains("something that does not exist" )); } }
这个测试用例的结果是这样的:
如果代码如期工作并抛出异常,那个异常就被catch代码块捕获,于是测试通过。
如果代码没有 如期工作,也就是没有抛出异常,则方法返回,测试通过,我们并未意识到代码有任何问题。
解法:
增加对JUnit中fail()方法的调用1,使得测试起作用了。现在除非抛出期望的异常,否则测试就失败。
1 2 3 4 5 6 7 8 9 10 @Test public void includeForMissingResourceFails () { try { new Environment ().include("something that does not exist" ); fail(); } catch (IOException e) { assertThat(e.getMessage(), contains("something that does not exist" )); } }
此外,JUnit4引入的一个新特性是@Test注解的expected属性。这个属性向JUnit声明你期望测试方法会抛出指定类型的异常,否则测试应当失败。那么就可以去掉了整个try-catch代码块和容易遗漏的fail()调用。
1 2 3 4 @Test(expected = IOException.class) public void includingMissingResourceFails () { new Environment ().include("somethingthatdoesnotexist" ); }
除了宣讲“不要犯错”,防止偶然地写一个永不失败的测试,最好的方法就是养成运行测试的习惯,或许是临时修改被测代码来故意触发一次失败,从而看到它失败。
轻率的承诺 正如欧美国家的政客,为了在选举中领先,政客们在几乎所有电视频道中辩论和接受采访,做出不会兑现的承诺——总有好借口,但总是太过轻率。
轻易承诺的潜在意思是测试用例做得比说得少——或根本没做啥。通常有三类表现:
测试无所事事。
测试实际上没有测试 任何东西。
测试名不符实。
第一种可以说是最公然地辜负了程序员的信任。
1 2 3 4 5 6 7 8 9 10 @Test public void filteringObjects () throws Exception { }
这个清单中见到所有的测试内容都注释掉了。基本上,这个测试方法是空操作,应当忽略掉。实际上,我们的测试框架会运行这个测试并报告成功,潜在地误导着程序员,他在IDE中看到测试标注为通过,还以为测试名称所描述的功能已经存在了。换句话说,这像是注释掉的测试 ,但是更糟!
而且,当我们扫视这个注释掉的测试,我们无法忽视它。代码写出来然后注释掉,这总有理由的——但我们不知道。这个测试应该通过吗?是否某人在做实验之后忘记了去掉注释?我们不知道。
尽管不常见到整个注释掉的或者无所事事的测试,我们第二轻易承诺的原型例子更为常见。
1 2 3 4 5 6 7 @Test public void cloneRetainsEssentialDetails () throws Exception { Document model = ModelFactory.createDocument(); model.schedule(new Transaction ("Test" , date("2010-01-20" ), 1 )); model = model.clone(); Transaction tx = model.getScheduledTransactions().get(0 ); }
乍看没什么问题。它短小、相当平衡,而且具有清楚、可理解的名字——克隆体包含基本的细节(clone retains essential details)。但是仔细一看,发现这个测试实际根本没有检查 克隆体是否包含基本的细节。没有assertTrue、assertEquals、assertThat——没有任何断言。那意味着无论克隆体的行为如何,这个测试都会通过。具体来说,只有当运行代码时抛出了异常,测试才会注意到克隆体没有按要求工作。
有人叫这种测试为**正常路径测试(Happy Path)**,因为它们往往不会失败,总是表示一切正常。
第三个例子描述如下。它或许代表了最常见的轻易承诺病理。
1 2 3 4 5 6 7 @Test public void zipBetweenTwoArraysProducesAHash () throws Exception { Array keys = new Array ("a" , "b" , "c" ); Array values = new Array (1 , 2 , 3 ); Array zipped = keys.zip(values); assertNotNull("We have a hash back" , zipped.flatten()); }
通读这个清单,你看到轻易的承诺了吗?测试的名字表明两个数组对象之间的Zip (压缩)操作后会产生一个哈希~~~~值操作后会产生一个哈希值。但测试只检查了zip方法返回了不为空的展平(flatten)数组。这与zip方法返回哈希值非常不同,你觉得呢?
这些测试的例子,都是说一套做一套,会让可怜的程序员长出白头发的,他还以为所述的行为存在并且正常工作,然而现实却恰恰相反。
解法:
首先,我们为什么要注释掉它?或许我们开始编写,然后半途意识到步子迈得太大了,注释掉它,换个测试的写法,却忘记回来充实注释掉的测试。或许我们尝试的一些事情破坏了测试,可能为了想要快速恢复回去,于是决定注释掉而不是删除它。
无论什么原因,都有更好的选择。头号原则就是删除代码,而不是注释掉。你有版本控制,对吧?而且,现代IDE具备本地历史,我们可以撤销变更,不会到达版本控制仓库。
一个空测试也比注释掉好得多,因为它是清楚的测试占位符——针对目前缺失、破碎或未验证的行为。更好的方法用JUnit的 @Ignore
注解来标记占位符,更加明确地指出那些测试还没有恰当地实现,且所描述的行为还不存在。更好的是拿起旧笔和纸。除了清晰地分开已实现与要实现的,纸笔不会拘泥于文字记录——你可以画草图,这是最自然的方式去描述你的想法。
对于没有断言的测试,解决方案很简单:确保你断言了一些事情,那么写测试时就从断言开始 。
当你先从输入最终的断言来开始写测试,你很难会从结果测试中偶然地移除断言。此外,从断言开始有助于专注在测试的本质上。具体哪个是我们要在测试中检查的行为?从断言开始还能帮助你避开第三个轻易承诺的例子——测试检查的远没有名字中表明的那样彻底。毕竟,当你只有测试名字和断言语句,几乎不可能看不 到它们之间的冲突。
另一个窍门是最初将测试名字留空,或者比如叫做TODO(),直到你完整地刻画出了测试。一旦你确切找出要检查的行为,就很容易命名测试。
这里的关键,以及所有这些窍门,就是使程序员(你)尽量简单地看到和注意到测试没有并做到它所声称的样子。考虑到这一点,仅仅保持测试小而专注已经是一个巨大的帮助了。
降低的期望 程序员往往注重细节。我们往往也会偷懒,经常找最简单的做事方式。这没错——很多时候我们应该做最简单的事情——但是有时我们过分了,结果搬起石头砸了自己的脚。因为采取了简化方式,这个测试坏味道就叫做降低的期望 ,代价就是降低了确定性与精确度的标准。这种捷径常常能帮你走得更快,也足以造成新增代码与程序的输出或行为有所不同。长期来看,这种测试由于不够精确,会造成一种虚假的安全感。
下面的例子受困于之前讲过的分割逻辑,以及降低的期望。看看你能否认出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class ComplexityCalculatorTest { private ComplexityCalculator complexity; ... @Test public void complexityIsZeroForNonExistentFiles () { assertEquals(0.0 , complexity.of(new Source ("NoSuchFile.java" ))); } @Test public void complexityForSourceFile () { double sample1 = complexity.of(new Source ("test/Sample1.java" )); double sample2 = complexity.of(new Source ("test/Sample2.java" )); assertThat(sample1, is(greaterThan(0.0 ))); assertThat(sample2, is(greaterThan(0.0 ))); assertTrue(sample1 != sample2); } }
解法:
ComplexityCalculator
计算指定源文件的圈复杂度,对于它的行为,清单中的测试类定义了期望。
首先,它已经很明显地体现了分割逻辑。测试在处理魔法数字,因为数字等于或大于0.0的原因隐藏在两个源文件中,引用路径为:test/Sample1.java 和 test/Sample2.java。这块不再赘述。
再看看中的complexityForSourceFile()
。注意是如何用三个断言来验证复杂度计算得正确。首先,我们验证两个源文件的结果都大于零,然后验证两个结果互不相同。假设这个测试通过,我们可以断定在计算背后一定有一些 逻辑。有趣的“三角法”导致稍微模糊的正确性断言,这正表现出降低的期望。基本上可以说,只要实际计算不为零而且两个源文件的结果不同就可以了。换句话说,那个测试对于变化来说极其健壮;以至于测试本该失败时也不会失败。
对于降低的期望,明显的解决方案就是提高门槛,使测试的期望更具体。你应该明确两个源文件的复杂度计算结果。简单的变更能将五行变成两行,如下:
1 2 3 4 5 @Test public void complexityForSourceFile () { assertEquals(2 , complexity.of(new Source ("test/Sample1.java" ))); assertEquals(5 , complexity.of(new Source ("test/Sample2.java" ))); }
平台偏见 很久以来,计算机这个词指的就是大家使用的某一个具体产品。然而,程序员愈发需要为他们的软件考虑多个平台和操作系统。尽管Java曾经定下了“编写一次,随处运行”的调子,但因为不是所有事情都能够抽象,我们还是发觉自己在编写特定于平台的功能。软件产品涉及多个平台,测试同样也会。平台偏见 测试坏味道可以描述为无法平等地应对所有平台。事实比那更复杂,但或许一个例子能表达得透彻。
下面的例子是针对底层操作系统,检查Configuration
类是否恰当地识别出默认的下载目录。
1 2 3 4 5 6 7 8 9 10 11 @Test public void knowsTheSystemsDownloadDirectory () throws Exception { String dir = Configuration.downloadDir().getAbsolutePath(); Platform platform = Platform.current(); if (platform.isMac()) { assertEquals(dir, matches("/Users/(.*?)/Downloads" )); } else if (platform.isWindows()) { assertThat(dir.toLowerCase(), matches("c:\\users\\(.*?)\\downloads" )); } }
解法:
最引人注目的是,当运行在不同平台时,if-else 将执行不同的断言。换句话说,在任何特定的平台上,只有一个分支会得到执行。
有几个理由说明这是个问题。首先,如果在Linux上运行会怎样?当运行在非Mac OS X或Windows平台时,清单中的测试就装聋作哑。其次,当运行测试时,我们只检查所在平台上的正确行为。这意味着无论其他平台上的行为是否遭到破坏,测试都将通过。
使那些针对特定平台分支可见的一种方式,是将其分割为多个测试,每个平台一个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class TestConfiguration { Platform platform; String downloadsDir; @Before public void setUp () { platform = Platform.current(); downloadsDir = Configuration.downloadDir().getAbsolutePath(); } @Test public void knowsTheSystemsDownloadDirectoryOnMacOsX () throws Exception { assumeTrue(platform.isMac()); assertEquals(downloadsDir, matches("/Users/(.*?)/Downloads" )); } @Test public void knowsTheSystemsDownloadDirectoryOnWindows () throws Exception { assumeTrue(platform.isWindows()); assertThat(downloadsDir.toLowerCase(), matches("c:\\users\\(.*?)\\downloads" )); } }
注意我们是如何添加守卫语句的,它使用了org.junit.Assume#assumeTrue()
假设性API,当测试没有运行在指定平台时,就中断执行。当该假设性API失败,JUnit并不会执行余下的部分。不幸的是,JUnit会将这些测试标记为通过,尽管我们的确不知道功能在那些平台上是否正确。
即使特定于平台的测试现在对外部可见,还有第二个和更多的基本问题:只有其中的一个测试会执行。为了全部运行三个测试,需要三个不同的计算机。这个问题源于被测代码的设计,而我们的测试坏味道强调了重构的需要。
我们应该尽量重构代码,这样能一个测试接一个测试来替换掉Platform,允许我们在任何平台上运行测试。
1 2 3 4 5 6 7 8 9 10 11 12 @Test public void knowsTheSystemsDownloadDirectoryOnMacOsX () throws Exception { String downloadsDir = new MacOsX ().downloadDir(); assertEquals(downloadsDir, matches("/Users/(.*?)/Downloads" )); } @Test public void knowsTheSystemsDownloadDirectoryOnWindows () throws Exception { String downloadsDir = new Windows ().downloadDir(); assertThat(downloadsDir.toLowerCase(), matches("c:\\users\\(.*?)\\downloads" )); }
注意我们直接实例化了具体的Platform子类,而非通过间接的Platform.current(),然后根据Platform的变化来运行条件化的测试,以解决这个问题。我们需要在某处测试Platform.current()的平台检测行为,但那些复杂行为不属于处理平台实现的测试。
有条件的测试 现代编程语言挺有意思的。多年来我们见证了语言进化和API扩展。别的不说,我们看到了语言结构的方式由库演变成核心语言。但是当错误地使用时,更强大却并不见得好,有条件测试坏味道正是一个佐证。
下列测试代码检查负责执行系统命令的Process
对象的行为。这个特殊的测试会检查参数是否正确地传入系统命令行。
1 2 3 4 5 6 7 8 9 @Test public void multipleArgumentsAreSentToShell () throws Exception { File dir = createTempDirWithChild("hello.txt" ); String[] cmd = new String [] { "ls" , "-1" , dir.getAbsolutePath() }; Process process = new Process (cmd).runAndWait(); if (process.exitCode() == 0 ) { assertEquals("hello.txt" , process.output().trim()); } }
首先,测试用一个叫做hello.txt的文件创建了临时目录,然后列出临时目录的列表。接下来,我们有个隐藏的条件。如果进程干净地退出(退出码为0),我们断言目录列表精确地包含我们期望见到的给定参数。
条件分支的问题在于它会等于false,随后,断言并不会执行而测试会默默地通过。例如,我们要是破坏了Process#runAndWait()
方法,任何系统命令都将失败,返回一个非零退出码。清单的测试将兴高采烈地通过,尽管并没有什么实际上能够工作。
解法:
再次重申,当被测行为遭到破坏时,测试就应该失败。如果有什么不对劲儿,你应该大声说出来,而不是默默地承受。当测试中误打误撞产生了条件分支,你应该确保测试在每个条件分支时都有机会失败。
1 2 3 4 5 6 7 8 @Test public void multipleArgumentsAreSentToShell () throws Exception { File dir = createTempDirWithChild("hello.txt" ); String[] cmd = new String [] { "ls" , "-1" , dir.getAbsolutePath() }; Process process = new Process (cmd).runAndWait(); assertEquals(0 , process.exitCode()); assertEquals("hello.txt" , process.output().trim()); }
增加了那个断言后,除非进程正常退出,否则测试将失败,这就解决了之前由条件语句造成的问题。
除了确保每个条件分支都将在适当的时候触发失败,你应该考虑将每个分支变成它自己的测试。毕竟,如果条件总是评估(evaluate)为相同的结果,它就失去了意义,那么显然软件应该期待多个潜在的执行路径。在之前的简单例子中,备选的执行路径应是命令执行失败的情况。因此,你可以增加一个新测试,检查退出码为非零。
1 2 3 4 5 6 @Test public void returnsNonZeroExitCodeForFailedCommands () { String[] cmd = new String [] { "this" , "will" , "fail" }; Process process = new Process (cmd).runAndWait(); assertThat(process.exitCode(), is(greaterThan(0 ))); }
添加以后,原清单中的所有条件分支全都用测试覆盖到了,不再受同样问题的煎熬。我们最初的测试确保参数传入了底层的命令行(shell),并且有测试看到当系统命令失败时,退出码发出失败信号。
可测试性的设计 略,请阅读正版原书
附录:Java单元测试的基本语法 略,请阅读正版原书
附录:测试替身(Test Double) 略,请阅读正版原书
附录:利用其他JVM语言如Groovy编写自动化测试 略,请阅读正版原书