首页 > 代码库 > 重构笔记——构筑测试体系
重构笔记——构筑测试体系
本文是在学习中的总结,欢迎转载但请注明出处:http://blog.csdn.net/pistolove/article/details/42167015
作为一名程序员,不知你是否在开发过程中也很少甚至不写测试程序,可能大多数人觉得这很正常,其实从个人角度来看也很正常,因为毕竟有测试人员专门进行测试的嘛!但是,如果能够认真观察程序员把最多时间耗在哪里,你就会发现,编写代码其实只占非常小的一部分。有些时间用来决定下一步干什么,另一些时间花在设计上,最多的时间则是用来调试。我敢肯定每一位读者都还记得自己花在调试上的无数个小时,无数次通宵达旦。每个程序员都能讲出花一天(甚至更多)时间只为找出一个小问题的故事。修复错误通常是很快的,但找出错误却是噩梦一场。当你修好一个错误时,总会有另一个错误出现,而且肯定要很久以后才会注意到它。那时你又得花时间去寻找它。看来我们真的有必要构筑测试体系了。
(Ⅰ)自测试代码的价值
其实,如果认真观察程序员把最多时间耗在哪里,你就会发现,编写代码其实只占非常小的一部分。有些时间用来决定下一步干什么,另一些时间花在设计上,最多的时间则是用来调试。我敢肯定每一位读者都还记得自己花在调试上的无数个小时,无数次通宵达旦。每个程序员都能讲出花一天(甚至更多)时间只为找出一个小问题的故事。修复错误通常是很快的,但找出错误却是噩梦一场。当你修好一个错误时,总会有另一个错误出现,而且肯定要很久以后才会注意到它。那时你又得花时间去寻找它。
所以,我们应该以长远的眼光来看待问题,而不能为了一时的方便造成以后的诸多不便。就像我们经常会对自己说:“呃,还不错,看起来已经能正常工作了,然后把已开发的代码仍在一边接着开发其它内容”。真的能正常工作?你亲眼看见它正常工作了吗?你测试过了吗?我们经常会自欺欺人,可能也不得不自欺欺人,因为开发人员哪有那么多时间的呀!但是,我们还是应该构筑测试体系,找出其中的问题。然而,编写优良的测试程序代码,可以极大提高我的编程速度,即使不进行重构也一样如此。
我觉得每个类都应该有一个测试函数,并以它来测试自己这个类。一般情况下,我们都会进行增量式开发,所以在结束每次增量时,最好能够为每个类添加测试,以确保正确性。就像之前在开发的项目比较小的时候,大约每周增量一次,执行的测试也相当简单,尽管如此,做这些测试还是很麻烦的,因为每个测试都把结果输出到控制台,而又必须逐一检查它们。后来,意识到其实完全不必要盯着屏幕检查测试所得信息是否正确,大可以让计算机帮助来做。这样,就能确保所有测试都完全自动化,让它们检查自己的测试结果。
一套测试就是一个强大的Bug侦探器,能够大大缩减查找Bug所需要的时间。当然,我们要说服别人也这么做并不是那么容易。编写测试程序,意味着要写很多额外的代码。除非你确切体验到这种方法对编程速度的提升,否则自我测试就显示不出它的意义。很多人根本没学过如何编写测试程序,甚至根本没考虑过测试,这对于编写自我测试代码也很不利。如果需要手动运行测试,那更是令人郁闷,没人愿意一直盯着屏幕看;但是如果可以自动运行,编写测试代码就真的很有趣。
通常情况下,我们都认为测试代码应该在程序开发完成后再进行撰写。实际上,撰写测试代码的最有用的时机是在开始编程之前。每当你需要添加特性的时候,先写相应测试代码。这听起来好像离经叛道,其实不然。因为编写测试代码其实就是在问自己:添加这个功能到底要做些什么。编写测试代码还能够让你把注意力集中于接口而非实现,这永远都是好事。预先写好的测试代码也为你的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
大道理听起来总是对的,尽管我相信每个人都可以从编写自我测试中受益,但这不是本文的重点。本文主要谈重构,而重构需要测试。如果你想重构,就必须编写测试代码。在JAVA中,常用的测试手法是testing main,意思是每个类都应该有一个用于测试的main()。这是一个合理的习惯,但可能不好操纵,这种做法其实很难轻松运行多个测试。比较好的做法是:建立一个独立类用于测试,并在一个框架中运行它,使测试工作更轻松。
(Ⅱ)JUnit测试框架
本文用JUnit测试框架,这个框架非常简单,却可让你进行测试所需的所有重要事情。下面将介绍用其为一些IO类开发测试代码。注:(a)TestCase(测试夹具)继承了Asset类并实现了Test接口。(b)TestSuite(测试套件)实现了Test接口。
(1)首先创建一个FileReaderTester类来测试文件读取器。任何包含测试代码的类(即测试用例)都必须继承测试框架所提供的TestCase类。其中Test是测试套件,其可以包含测试用例和其它测试套件。
public class FileReaderTester extends TestCase { public FileReaderTester(String method) { super(method); } }(2)其次,这个新建的类需要有一个构造函数。完成后我们就可添加测试代码,首选需要设置测试夹具,即测试的对象样本。由于需要读到一个文件,所以先准备一个如下的测试文件:
Bradman 99.95 50 82 10 6789 334 29 Pollock 60.95 22 60 4 2256 334 4 Marry 80.95 56 88 4 2256 334 29 Jone 77.65 70 67 4 6789 334* 11 Sutcliffe 63.25 56 80 8 6789 331934 18
(3)进一步运用这个文件之前,我们要准备好测试夹具。TestCase提供两个函数:setUp()用来产生相关对象,tearDown()负责删除它们现在我们有适当的测试夹具,就可以开始编写测试代码了。首先测试read(),读取一些字符,然后检查后续读取的字符是否正确。
public void testRead throws Ioexception{ char ch = '&'; for(int i = 0; i < 4; i++){ ch = (char)_input.read(); assert('d' == ch) } }assert()扮演自动测试角色。如果assert()的参数值为true,一切良好,否则我们就会收到错误通知。下面介绍如何将测试过程运行起来:
(1)第一步产生一个测试套件。为此设计一个suite(),如下所示:
public static Test suite() { TestSuite suite = new TestSuite(); suite.addTest(new FileReaderTester("testRead")); suite.addTest(new FileReaderTester("testReadAndEnd")); return suite; }
这个测试套件只含有一个测试用例对象,即FileReaderTester实例。创建测试用例对象时,把待测函数的名称以字符串的形式传给构造函数,从而创建出一个对象,用以测试被指定的函数,这个测试通过JAVA反射机制和对象关联。
(2)还需要一个独立的TestRunner类,TestRunner有两个版本,这里我们选择“文字界面”版本。对于每个运行起来的测试,JUnit都会通过良好的用户界面输出,这样你就可以直观地看到测试进展。它会告诉你整个测试花了多少时间。如果所有测试没有出错,它就会说OK,并告诉你运行了多少个测试。
public static void main(String[] args) { junit.textui.TestRunner.run(suite()); }这段代码创建出一个TestRunner,并要它运行FileReaderTester类。但我们执行时候,会看到:
频繁地运行测试,每次编译请把测试也考虑进去——每天至少执行每个测试一次。在重构的过程中,你可以只运行少数几项测试,它们主要用来检查当下正在开发或整理的代码。是的,你可以运行少数几项测试,这样肯定比较快,否则整个测试会降低你的开发速度,使你开始犹豫是否要这样进行下去。如果测试出错,会发生什么事?为了展示我故意引进bug,使得找不到测试文件。会看到:
这样我们就能够很快发现错误在哪里,并对代码进行修改。在编写测试代码时,我们应该先让它失败。之所以这么做是为了证明:测试机制的确可以运行,并且测试的确测试了它该测试的东西。
下面给出上面运行的详细代码,让你能够更好地了解测试代码:
import java.io.FileReader; import java.io.IOException; import junit.framework.Test; import junit.framework.TestCase; import junit.framework.TestSuite; public class FileReaderTester extends TestCase { private FileReader _input = null; public FileReaderTester(String method) { super(method); } public static void main(String[] args) { junit.textui.TestRunner.run(suite()); } @Override protected void setUp() throws Exception { try { _input = new FileReader("data.txt"); } catch (Exception e) { throw new RuntimeException("不能打开测试文件"); } } @Override protected void tearDown() throws Exception { try { _input.close(); } catch (Exception e) { throw new RuntimeException("关闭测试文件错误"); } } public void testRead() throws IOException { char ch = '&'; for (int i = 0; i < 4; i++) ch = (char) _input.read(); assertEquals('d', ch); } public static Test suite() { TestSuite suite = new TestSuite(); suite.addTest(new FileReaderTester("testRead")); return suite; } public void testReadAndEnd() throws IOException { int ch = 1234; for (int i = 0; i < 141; i++) ch = _input.read(); assertEquals(-1, ch); //比较是否相等 } }
(Ⅲ)单元测试和功能测试
JUnit框架的用途是单元测试。单元测试的目的是为了提高程序员的生产率。单元测试是高度局部化的东西,每个测试类都属于单一包。它能够测试其他包的接口,除此之外它将假设其他包一切正常。
功能测试就完全不同。它们用来保证软件能够正常运行。他们从客户的角度保障质量,并不关心程序员的生产力。它们应该由一个喜欢寻找bug的独立团队来开发。这个团队应该使用重量级工具和技术帮助自己开发良好的功能测试。一般而言,功能测试尽可能把整个系统当作一个黑箱。面对一个拥有GUI的待测系统,它们通过GUI来操作那个系统。面对文件更新程序或数据库更新程序,功能测试只观察特定输入所导致的数据变化。
一旦功能测试者或最终用户找到软件中的bug,要除掉它至少需要做两件事。当然必须要修改代码,才能排除错误,但你还应该添加一个单元测试,用来暴露这个bug。事实上,我们应该在收到bug报告时,首先编写一个单元测试,使bug浮现出来。
(Ⅳ)添加更多的测试
现在我们应该添加更多的测试。我们遵循的原则是:观察类该做的所有事情,然后针对任何一项功能的任何一种可能失败情况,进行测试。记住,测试应该是一种风险驱动的行为,测试的目的是希望找出现在或未来可能出现的错误。所以我不会去测试那些仅仅读或写一个字段的访问函数,因为他们太简单了,不大可能出错。这一点也很重要,撰写过多的测试,往往测试量反而不够。测试你最担心出错的地方,这样你就能从测试工作中得到最大利益。
(1)考虑可能出错的边界,把测试火力集中在那儿。
测试的一项重要技巧就是“寻找边界条件”。对于read()而言,边界条件应该是第一个字符、最后一个字符、倒数第二个字符:
public void testReadBoundaries() throws IOException { assertEquals("read first char", 'B', _input.read()); int ch; for (int i = 0; i < 222; i++) ch = _input.read(); assertEquals("read last char", 8, _input.read()); assertEquals("read first char", -1, _input.read()); }
你可以在断言中加一条消息。如果测试失败,这条消息就会被显示出来。
“寻找边界条件”也包括寻找特殊的、可能导致测试失败的情况。对于文件相关测试,空文件是个不错的边界条件:
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()); }(2)当事情被认为应该会出错时,别忘了检查是否抛出了预期的异常。
测试时,别忘了检查预期的错误时候如期出现。如果你尝试在关闭流后再读取它,就应该得到一个IOException,这也应该被测试出来:
public void testReadAfterClose() throws IOException { _input.close(); try { _input.read(); fail("no exception for read past end"); } catch (IOException e) { } }遵循这些规则,不断丰富你的测试。对于某些比较复杂的类,可能需要花费一些时间来浏览其接口,而在这个过程中你可以真正理解这个接口。随着测试类愈来愈多,你可以生成另一个类,专门用来包含由其他测试类所组成的测试套件。
class MasterTester extends TestCase{ public static void main(String[] args) { junit.textui.TestRunner.run(suite()); } public static Test suite(){ TestSuite suite = new TestSuite(); suite.addTest(new TestSuite(FileReaderTester.class)); suite.addTest(new TestSuite(FileWriterTester.class)); //...... return suite; } }(3)不要因为测试无法捕捉所有bug就不写测试,因为测试的确可以捕捉到大多数bug。
对象技术有个“微妙”之处:继承和多态会让测试变得比较困难,因为将有许多种组合需要测试。如果你有3个彼此合作的抽象类,每个抽象类有3个子类,那么你总共拥有9个可供选择的类和27种组合。并不是要试着测试所有可能组合,我们并没有那么多的时间,但是也应该尽量测试尽可能多的类,这可以大大减少各种组合所造成的风险。我们总可能遗漏些什么,但是我觉得“花一些合理的时间抓住大多数bug”要好过“穷尽一生抓所有bug”。
本文主要介绍了自测试代码的价值、运用JUnit测试框架进行简单的测试,目的就是为了说明构建自动测试对于开发人员来说是很重要的。其实,我们缺少的不是解决问题的能力,而缺少的是很难发现问题到底出在哪儿的能力。与其花那么多的时间去寻找一个bug,还不如在开发的过程中就将其扼杀掉。本文介绍的内容还是偏向理论方面,但是不得不介绍,因为重构需要一个可靠的测试环境,即使不重构编写测试程序也很重要。
PS:下一篇文章将正式开启重构之门:重构笔记——提炼函数。希望本文对你有所帮助。
重构笔记——构筑测试体系