重构笔记——构筑测试体系

本文是在学习中的总结,欢迎转载但请注明出处: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:下一篇文章将正式开启重构之门:重构笔记——提炼函数。希望本文对你有所帮助。



重构笔记之前文章如下:

       重构笔记——入门篇

       重构笔记——代码的坏味道(上

       重构笔记——代码的坏味道(下)


评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值