ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
### 添加更多测试 现在,我们应该继续添加更多测试。我遵循的风格是:观察class该做的所有事情,然后针对任何一项功能的任何一种可能失败情况,进行测试。这不同于某些程序 员提倡的「测试所有public函数」。记住,测试应该是一种风险驱动(risk driven)行为,测试的目的是希望找出现在或未来可能出现的错误。所以我不会去测试那些仅仅读或写一个值域的访问函数(accessors),因为它们太简单了,不大可能出错。 这一点很重要,因为如果你撰写过多测试,结果往往测试量反而不够。我常常阅读许多测试相关书籍,我的反应是:测试需要做那么多工作,令我退避三舍。这种书起不了预期效果,因为它让你觉得测试有大量工作要做。事实上,哪怕只做一点点测试,你也能从中受益。测试的要诀是:测试你最担心出错的部分。这样你就能从测试工作中得到最大利益。 TIP:编写未臻完善的测试并实际运行,好过对完美测试的无尽等待。 现在,我的目光落到了read()。它还应该做些什么?文档上说,当input stream到达文件尾端,应该返回-1 (在我看来这并不是个很好的协议,不过我猜这会让C程序员倍感亲切)。让我们来测试一下。我的文本编辑器告诉我,我的测试文件共有141个字符,于是我撰写测试代码如下: ~~~ public void testReadAtEnd() throws IOException { int ch = -1234; for (int i = 0; i < 141; i++) ch = _input.read(); assertEquals(-1, ch); } ~~~ 为了让这个测试运行起来,我必须把它添加到test suit(测试套件)中: ~~~ public static Test suite() { TestSuite suite= new TestSuite(); suite.addTest(new FileReaderTester("testRead")); suite.addTest(new FileReaderTester("testReadAtEnd")); return suite; } ~~~ 当test suit (测试套件)运行起来,它会告诉我它的每个成分——也就是这两个test cases (测试用例)——的运行情况。每个用例都会调用tearDown(),然后执行测试代码,最终调用tearDown()。每次测试都调用setUp()和tearDown()是很重要的,因为这样才能保证测试之间彼此隔离。也就是说我们可以按任意顺序运行它们,不会对它们的结果造成任何影响。 老要记住将test cases添加到suite(),实在是件痛苦的事。幸运的是Erich Gamma和Kent Beck和我一样懒,所以他们提供了一条途径来避免这种痛苦。TestSuite class有个特殊构造函数,接受一个class为参数,创建出来的test suite会将该class内所有以"test"起头的函数都当作test cases包含进来。如果遵循这一命名习惯, 就可以把我的main()改为这样: ~~~ public static void main (String[] args) { junit.textui.TestRunner.run (new TestSuite(FileReaderTester.class)); } ~~~ 这样,我写的每一个测试函数便都被自动添加到test suit 中。 测试的一项重要技巧就是「寻找边界条件」。对read()而言,边界条件应该是第一个字符、最后一个字符、倒数第二个字符: ~~~ public void testReadBoundaries()throwsIOException { assertEquals("read first char",'B', _input.read()); int ch; for (int i = 1;i <140; i++) ch = _input.read(); assertEquals("read last char",'6',_input.read()); assertEquals("read at end",-1,_input.read()); } ~~~ 你可以在assertions中加入一条消息。如果测试失败,这条消息就会被显示出来。 TIP:考虑可能出错的边界条件,把测试火力集中在那儿。 「寻找边界条件」也包括寻找特殊的、可能导致测试失败的情况。对于文件相关测 试,空文件是个不错的边界条件: ~~~ public void testEmptyRead()throws IOException { File empty = new File ("empty.txt"); FileOutputStream out = new FileOutputStream (empty); out.close(); FileReader in = new FileReader (empty); assertEquals (-1, in.read()); } ~~~ 现在我为这个测试产生一些额外的访对test fixture (测试装备)。如果以后还需要空文件,我可以把这些代码移至setUp(),从而将「空文件」加入常规test fixture。 ~~~ protected void setUp(){ try { _input = new FileReader("data.txt"); _empty = newEmptyFile(); } catch(IOException e){ throw new RuntimeException(e.toString()); } } private FileReader newEmptyFile() throws IOException { File empty = new File ("empty.txt"); FileOutputStream out = new FileOutputStream(empty); out.close(); return newFileReader(empty); } public void testEmptyRead() throws IOException { assertEquals (-1, _empty.read()); } ~~~ 如果读取文件末尾之后的位置,会发生什么事?同样应该返回-1。现在我再加一个测试来探测这一点: ~~~ public void testReadBoundaries()throwsIOException { assertEquals("read first char",'B', _input.read()); int ch; for (int i = 1;i <140; i++) ch = _input.read(); assertEquals("read last char", '6', _input.read()); assertEquals("read at end",-1,_input.read()); assertEquals ("readpast end", -1, _input.read()); } ~~~ 注意,我在这里扮演「程序公敌」的角色。我积极思考如何破坏代码。我发现这种思维能够提高生产力,并且很有趣。它纵容了我心智中比较促狭的那一部分。 测试时,别忘了检查预期的错误是否如期出现。如果你尝试在stream被关闭后再读 取它,就应该得到一个IOException异常,这也应该被测试出来: ~~~ public void testReadAfterClose() throwsIOException{ _input.close(); try { _input.read(); fail ("no exception for read past end"); } catch (IOException io) {} } ~~~ IOException之外的任何异常都将以一般方式形成一个错误。 TIP: 当事情被大家认为应该会出错时,别忘了检查彼时是否有异常如预期般地被拋出。 请遵循这些规则,不断丰富你的测试。对于某些比较复杂的,可能你得花费 一些时间来浏览其接口,但是在此过程中你可以真正理解这个接口。而且这对于考虑错误情况和边界情况特别有帮助。这是在编写代码的同时(甚至之前)编写测试代码的另一个好处。 随着tester classes愈来愈多,你可以产生另一个class,专门用来包含「由其他tester classes所形成」的测试套件(test suite)。这很容易做到,因为一个测试套件本来就可以包含其他测试套件。这样,你就可以拥有一个「主控的」(master)test class: ~~~ class MasterTester extends TestCase { public static void main (String[] args) { junit.textui.TestRunner.run (suite()); } public static Test suite() { TestSuite result = new TestSuite(); result.addTest(new TestSuite(FileReaderTester.class)); result.addTest(new TestSuite(FileWriterTester.class)); // and so on... return result; } } ~~~ 什么时候应该停下来?我相信这样的话你听过很多次:「任何测试都不能证明一个程序没有臭虫」。这是真的,但这不会影响「测试可以提高编程速度」。我曾经见过数种测试规则建议,其目的都是保证你能够测试所有情况的一切组合。这些东西值得一看,但是别让它们影响你。当测试数量达到一定程度之后,测试效益就会呈现递减态势,而非持续递增;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。你应该把测试集中在可能出错的地方。观 察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。是的,你的测试不可能找出所有臭虫,但一旦进行重构,你可以更好地理解整个程序,从而找到更多臭虫。虽然我总是以单独一个测试套件开始重构,但前进途中我总会加入更多测试。 TIP:不要因为「测试无法捕捉所有臭虫」,就不撰写测试代码,因为测试的确可以描捉到大多数臭虫。 对象技术有个微妙处:继承(inheritance)和多态(polymorphism )会让测试变得比较困难,因为将有许多种组合需要测试。如果你有3个彼此合作的abstract classes ,每个abstract classes 有三个subclasses,那么你总共拥有九个可供选择的classes,和27种组合。我并不总是试着测试所有可能组合,但我会尽量测试每一个classes,这可以大大减少各种组合所造成的风险。如果这些classes之间彼此有合理的独立性,我很可能不会尝试所有组合。是的,我总有可能遗漏些什么,但我觉得「花合理时间抓出大多数臭虫」要好过「穷尽一生抓出所有臭虫」。 测试代码和产品代码(待测代码)之间有个区别:你可以放心地拷贝、编辑测试代 码。处理多种组合情况以及面对多个可供选择的classes时,我经常这么做。首先测试「标准发薪过程」,然后加上「资历」和「年底前停薪」条件,然后又去掉这两个条件……。只要在合理的测试装备(test fixture)上准备好一些简单的替换样本,我就能够很快生成不同的test case (测试用例),然后就可以利用重构手法分解出真正常用的各种东西。 我希望这一章能够让你对于「撰写测试代码」有一些感觉。关于这个主题,我可以说上很多,但如果那么做,就有点喧宾夺主了。总而言之,请构筑一个良好的臭虫检测器(bug detector)并经常运行它;这对任何开发工作都是一个美好的工具,并且是重构的前提。