人们在谈论测试的时候所说的通常都是那些被用来寻找bug的测试,而这些测试通常又都是手动测试。对于遗留代码来说,编写自动化的测试来寻找bug常常让人感觉还没有直接运行代码来得高效。如果你有办法直接手动测试遗留代码的话,往往能很快找到bug。缺点是伴随着一次次的代码修改,每次都得从头手动测试一遍。而且坦白地说,事实上人们并不采用这种办法。在我所接触过的团队中,几乎每一个依赖于手动测试的团队最终都远远落在了后面,结果团队的信心大受打击。
别误会,在遗留代码中寻找bug通常并不是问题。从策略上来说,把工夫花在这上面很可能是将力气用错了地方。通常还不如把精力放在如何让你的团队始终能够编写出正确的代码上面。一句话,正确的策略是关心如何才能从一开始就避免让bug进入代码。
自动化测试是一个非常有用的工具,但这并非对寻找bug而言,至少并没有直接的关系。一般而言,自动化测试的任务是明确说明一个我们想要实现的目标,或者试图保持代码中某些既有的行为。在自然的开发流程中,属于前者的测试逐渐就会变成后者。当然,你会遇到bug,但通常并非在某个测试第一次运行的时候,而是在不小心改变了不想改变的行为时,这时运行测试就会指出问题。
那么对于遗留代码而言,这意味着什么呢?对于要在遗留代码中进行的修改,我们可能尚没有任何针对性的测试,于是也就没有办法去验证在修改之后是否有什么行为被破坏了。所以,最好就是把我们想要修改的那一块区域先用测试给罩起来,像安全网那样。然后,在修改的过程中我们会发现bug,并解决它们,但是对于大多数遗留代码来说,若是我们将寻找和修正所有bug当成目标的话,则永远也不会有做完的一天。
13.1 特征测试
好吧,我们需要测试,但问题是如何编写它们呢?办法之一便是先搞清你的软件应当能做什么,然后基于你所获得的认识去编写测试。我们可以把那些落了灰的需求文档和项目备忘录翻出来,努力从中挖掘出我们想要的信息,之后便坐下来开始编写测试。这的确是个办法,但并不算很好。因为对于几乎所有的遗留系统而言,更重要的不是“系统应该能够做些什么”,而是“系统当前能够做些什么”。所以如果我们基于从文档当中发掘出来的关于“系统应该能够做些什么”的假设来编写测试的话,就又回到了寻找bug的老路上了。寻找bug的确很重要,但我们当前的目标是把测试安置到位,从而减少代码修改过程当中的不确定性。
我把用于行为保持的测试称为特征测试(Characterization test)。特征测试刻画了一块代码的实际行为。而不是“嗯……这块代码应该具有这一行为”或者“我想它会那样的吧”。特征测试描述了系统当前的实际行为。
以下是编写特征测试的几个步骤:
(1) 在测试用具中使用目标代码块。
(2) 编写一个你知道会失败的断言。
(3) 从断言的失败中得知代码的行为。
(4) 修改你的测试,让它预期目标代码的实际行为。
(5) 重复上述步骤。
在下面这个例子中,我相当确信一个PageGenerator对象不会生成字符串"fred",所以我写下一个断言:
void testGenerator() {
PageGenerator generator = new PageGenerator();
assertEquals("fred", generator.generate());
}
运行测试,看它是否失败。一旦果真失败了,你也就明确地知道了代码当前在那种情况下的实际行为。例如在上面的代码中,一个新建的PageGenerator对象在它的generate方法被调用时返回了一个空串:
.F
Time: 0.01
There was 1 failure:
1) testGenerator(PageGeneratorTest)
junit.framework.ComparisonFailure: expected:<fred> but was:<>
at PageGeneratorTest.testGenerator
(PageGeneratorTest.java:9)
at sun.reflect.NativeMethodAccessorImpl.invoke0
(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke
(NativeMethodAccessorImpl.java:39)
at sun.reflect.DelegatingMethodAccessorImpl.invoke
(DelegatingMethodAccessorImpl.java:25)
FAILURES!!!
Tests run: 1, Failures: 1, Errors: 0
我们可以修改测试从而让它能够通过:
void testGenerator() {
PageGenerator generator = new PageGenerator();
assertEquals("", generator.generate());
}
现在测试通过了。而且,不仅是通过,它还起到了描述PageGenerator的一个最基本行为的作用,这个最基本的行为就是:如果我们创建一个PageGenerator并立即调用它的generate方法的话,就会得到一个空串。
可以使用同样的技巧来查明当我们给PageGenerator提供其他数据的时候会生成什么:
void testGenerator() {
PageGenerator generator = new PageGenerator();
generator.assoc(RowMappings.getRow(Page.BASE_ROW));
assertEquals("fred", generator.generate());
}
对于上面的测试,测试用具给出的出错信息告诉我们结果串是"<node><carry>1.1 vectrai</carry></node>",于是我们可以把这个串填入测试当中所期望的结果串那儿。
void testGenerator() {
PageGenerator generator = new PageGenerator();
assertEquals("<node><carry>1.1 vectrai</carry></node>",
generator.generate());
}
不过,如果你已经习惯了把这些测试也当作测试的话,就会发现这种做法的一些很奇怪的地方。比如,既然我们只是把代码产生的实际结果填入测试,那这些所谓的测试就没任何意义了。要是代码本身就有bug的话,那我们填入测试的那些期望值岂不很可能都是错的?
然而,如果我们换个角度的话,这个问题就消失了。我们不把它们看成软件必须遵循的黄金准则,因为我们并不是为了寻找bug,我们是想设置一个机制以便于以后寻找bug。注意,这里所说的bug是指后面可能出现的、与当前系统行为不一致的行为。采用了这一视角之后,我们看待这类测试的方式也就相应发生了变化:它们不再是一个个的准则,而是描述了系统各部分的实际行为。一旦我们知道系统某部分的实际行为,结合之前对于系统“应该具有的行为”的认识,就可以明智地作出如何修改的决策。事实上,了解系统中某部分的实际行为是非常重要的。我们通常可以通过与其他人交流或者通过计算知道哪些行为是需要添加的,但若是少了(特征)测试的话,便没法知道系统当前的实际行为是什么了,当然,除非你在每次需要判断系统实际行为的时候都能够边读代码边在脑子里“运行”出结果来。对于后一种方式,有些人比较拿手,而有些人则不是,关键是不管我们“运行”得有多快,一遍遍地做这件事仍是相当乏味和浪费精力的。
特征测试描述了一块代码的实际行为。在编写特征测试的时候如果发现某些结果与我们所期望的不一致,最好弄清它。因为我们遇到的可能是个bug。但这并不是说我们就不能把该测试放进测试套装中,而是说我们应该将它标记为可疑的,并搞清修正它会带来哪些影响。
关于特征测试,目前我们所讲到的还只是很少一部分。比如在前面的PageGenerator例子当中,看起来当时我们就好像只是很随便地扔一个值给测试对象,然后通过断言来查看得到什么结果。当然,如果我们对代码所应当具有的行为有一个良好的把握的话,是可以这么做的。有些情况下,像一开始我们对PageGenerator所做的,只是创建一个对象,然后立即调用它的方法查看结果,这一工作概念简单,而且也的确值得编写特征测试,但问题是,接下来我们该做什么呢?还是拿PageGenerator例子来说,像这样一个类,我们到底可以为它编写多少(特征)测试呢?答案是,无穷多。花上十年八年也写不完。那么,什么时候应该停止呢?有什么办法可以告诉我们其中哪些测试更重要呢?
要想解答这个问题,关键的一点就是要意识到我们并不是在编写黑盒测试。换句话说,我们在编写特征测试的时候是可以去查看所要刻画的代码的。代码本身能够告诉我们它们的行为,而如果看了代码还不能肯定的话,理想的办法就是编写测试去“询问”它们了。编写特征测试的第一步就是让自己对目标代码的行为感到好奇,在这个阶段我们不断编写测试直到感到已经理解了代码。但这样我们的测试就能保证覆盖了代码的所有方面吗?还有第二步,就是设法弄清我们的修改如果引入了bug的话测试能否“感应”得到。如果存在可能的漏网之鱼,就要添加更多的测试,直到无遗漏为止。而如果我们没有这么大的信心,安全一点的办法就是考虑换一种方式来修改代码。或许我们可以再返回到第一步看看。
使用方法的规则
当准备在遗留系统中使用一个方法之前,请查看一下是否已有针对它的测试。没有的话就自己写一个。始终保持这一习惯,你的测试就能起到信息传递媒介的作用。别人只要一看到你的测试就能够知道对于某方法他们该期望什么而不该期望什么。试图使一个类变得可测试这一行为本身往往能够改善代码的质量。于是人们能够发现什么是可行的,以及是如何可行的,他们可以进行修改,更正bug,然后继续前进。







