💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
### JUnit测试框架 译注:本段将使用英文词:test-suite(测试套件)、test-case(测试用例)和test-fixture(测试装备),期能直接对应图4.1的JUnit结构组件,并有助于阅读JUnit文档。 我用的是JUnit,一个由Erich Gamma 和 Kent Beck [JUnit]开发的开放源码测试框架。这个框架非常简单,却可让你进行测试所需的所有重要事情。本章中我将运用这个测试框架来为一些IO classes幵发测试代码。 首先我创建一个FileReaderTester 来测试文件读取器。任何『包含测试代码」 的class都必须衍生自测试框架所提供的TestCase class。这个框架运用Composite 模式[Gang of Four],允许你将测试代码聚集到suites(套件)中,如图4.1。这些套件可以包含未加工的test-cases(测试用例),或其他test-suits(测试套件)。如此一来我就可以轻松地将一系列庞大的test-suits结合在一起,并自动运行它们。 ![](https://box.kancloud.cn/2016-08-15_57b1b56b75cf7.gif) 图4.1 测试框架的Composite结构 ~~~ class FileReaderTester extends TestCase { public FileReaderTester (String name) { super(name); } } ~~~ 这个新建的class必须有一个构造函数。完成之后我就可以开始添加测试代码了。我的第一件工作是设置test fixture(测试装备),那是指「用于测试的对象样本」。由于我要读一个文件,所以先备妥一个测试文件如下: ~~~ Bradman 99.94 52 80 10 6996 334 29 Pollock 60.97 23 41 4 2256 274 7Headley 60.83 22 40 4 2256 270* 10Sutcliffe 60.73 54 84 9 4555 194 16 ~~~ 进一步运用这个文件之前,我得先准备好test-fixture (测试装备)。TestCase class 提供两个函数专门针对此一用途:setUp()用来产生相关对象、tearDown()负责删除它们。在TestCase class中这两个函数都只有空壳。大多数时候你不需要操心test-fixture的拆除(垃圾回收器会扛起责任),但是在这里,以tearDown()关闭文件无疑是明智之举: ~~~ class FileReaderTester... protected void setUp() { try { _input = new FileReader("data.txt"); } catch (FileNotFoundException e) { throw new RuntimeException ("unable to open test file"); } } protected void tearDown() { try { _input.close(); } catch (IOException e) { throw new RuntimeException ("error on closing test file"); } } ~~~ 现在我有了适当的test-fixture(测试装备),可以开始编写测试代码了。首先要测试的是read(),我要读取一些字符,然后检查后续读取的字符是否正确: ~~~ public void testRead() throws IOException { char ch = '&'; for (int i=0; i < 4; i++) ch = (char) _input.read(); assert('d' == ch); } ~~~ assert()扮演自动测试角色。如果assert()的参数值为ture,一切良好;否则我们就会收到错误通知。稍后我会让你看看测试框架怎么向用户报告错误消息。现在我要先介绍如何将测试过程运行起来。 第一步是产生一个test-suite(测试套件)。为达目的,请设计一个suite()如下: ~~~ class FileReaderTester... public static Test suite() { TestSuite suite= new TestSuite(); suite.addTest(new FileReaderTester("testRead")); return suite; } ~~~ 这个测试套件只含一个test-case(测试用例)对象,那个是FileReaderTester实体。创建test-case对象时,我传给其构造函数一个字符串,这正是待测函数的名称。这会创建出一个对象,用以测试被指定的函数。这个测试系通过Java反射机制(reflection)和对象系结在一起。你可以自由下载JUnit源码,看看它究竟如何做到。至于我,我只把它当作一种魔法。 要将整个测试运行起来,还需要一个独立的TestRunner class。TestRunner 有两个版本,其中一个有漂亮的图形用户界面(GUI),另一个采用文字界面。我可以在main函数中调用「文字界面」版: ~~~ class FileReaderTester... public static void main (String[] args) { junit.textui.TestRunner.run (suite()); } ~~~ 这段代码创建出一个TestRunner,并要它去测试FileReaderTester class。当我执行它,我看到: ~~~ . Time: 0.110 OK (1 tests) ~~~ 对于每个运行起来的测试,JUnit都会输出一个句点,这样你就可以直观看到测试进展。它会告诉你整个测试用了多长时间。如果所有测试都没有出错,它就会说"OK",并告诉你运行了多少笔测试。我可以运行上千笔测试,如果一切良好,我会看到那个"OK"。对于自我测试代码来说,这个简单的响应至关重要,没有它我就不可能经常运行这些测试。有了这个简单响应,你可以执行一大堆测试然后去吃个午饭(或幵个会),回来之后再看看测试结果。 TIP:频繁地运行测试。每次编译请把测试也考虑进去——每天至少执行毎个测试一次。 重构过程中,你可以只运行少数几项测试,它们主要用来检查当下正在开发或整理的代码。是的,你可以只运行少数几项测试,这样肯定比较快,否则整个测试会减低你的开发速度,使你开始犹豫是否还要这样下去。千万别屈服于这种诱惑,否则 你一定会付出代价。 如果测试出错,会发生什么事?为了展示这种情况,我故意放一只臭虫进去: ~~~ public void testRead() throws IOException { char ch = '&'; for (int i=0; i < 4; i++) ch = (char) _input.read(); assert('2' == ch); //deliberate error } ~~~ 得到如下结果: ~~~ .F Time: 0.220 !!!FAILURES!!! Test Results: Run: 1 Failures: 1 Errors: 0 There was 1 failure: 1) FileReaderTester.testRead test.framework.AssertionFailedError ~~~ JUnit警告我测试失败,并告诉我这项失败具体发生在哪个测试身上。不过这个错误消息并不特别有用。我可以使用另一种形式的assert,让错误消息更清楚些: ~~~ public void testRead() throws IOException { char ch = '&'; for (int i=0; i < 4; i++) ch = (char) _input.read(); assertEquals('m',ch); } ~~~ 你做的绝大多数都是对两个值进行比较,检验它们是否相等,所以JUnit框架为你提供assertEquals()。这个函数很简单:以equals()进行对象比较,以操作符==进行数值比较——我自己常忘记区分它们。这个函数也输出更具意义的错误消息: ~~~ .F Time: 0.170 !!!FAILURES!!! Test Results: Run: 1 Failures: 1 Errors: 0 There was 1 failure: 1) FileReaderTester.testRead "expected:"m"but was:"d"" ~~~ 我应该提一下:编写测试代码时,我往往一开始先让它们失败。面对既有代码,要不我就修改它(如果我能碰触源码的话),使它测试失败,要不就在assertions中放一个错误期望值,造成测试失败。之所以这么做,是为了向自己证明:测试机制的确可以运行,并且的确测试了它该测试的东西(这就是为什么上面两种作法中我比较喜欢修改待测码的原因)。这可能有些偏执,或许吧,但如果测试代码所测的东西并非你想测的东西,你真的有可能被搞得迷迷糊糊。 除了捕捉「失败」(failures,也就是assertions之结果为"false"),JUnit还可以捕捉「错误」(errors,意料外的异常)。如果我关闭input stream,然后试图读取它,就应该得到一个异常(exception)。我可以这样测试: ~~~ public void testRead() throws IOException { char ch = '&'; _input.close(); for (int i=0; i < 4; i++) ch = (char) _input.read(); // will throw exception assertEquals('m',ch); } ~~~ 执行上述测试,我得到这样的结果: ~~~ .E Time: 0.110 !!!FAILURES!!! Test Results: Run: 1 Failures: 0 Errors: 1 There was 1 error: 1) FileReaderTester.testRead java.io.IOException: Stream closed ~~~ 区分失败(failures)和错误(errors)是很有用的,因为它们的出现形式不同,排除的过程也不同。 JUnit还包含一个很好的图形用户界面(GUI,见图4.2)。如果所有测试都顺利通过,窗口下端的进度杆(progress bar )就呈绿色;如果有任何一个测试失败,进度杆就呈红色。你可以丢下这个不管,整个环境会自动将你在代码所做的任何修改连接(links)进来。这是一个非常方便的测试环境。 ![](https://box.kancloud.cn/2016-08-15_57b1b56b8afe2.gif) 图4.2 JUnit的图形用户界面 **单元测试和功能测试** JUnit框架的用途是单元测试,所以我应该讲讲单元测试和功能测试之间的差异。 我一直挂在嘴上的其实是「单元测试」,编写这些测试的目的是为了提高身为一个程序员的生产性能。至于让质保部门开心,那只是附带效果而已。单元测试是高度本地化(localized)的东西,每个test class只对单一package运作。它能够测试其他packages的接口,除此之外它将假设其他一切正常。 功能测试就完全不同。它们用来保证软件能够正常运作。它们只负责向客户提供质量保证,并不关心程序员的生产力。它们应该由一个喜欢寻找臭虫的个别团队来开发。这个团队应该使用重量级工具和技术来帮助自己开发良好的功能测试。 一般而言,功能测试尽可能把整个系统当作一个黑箱。面对一个GUI待测系统,它 们通过GUI来操作那个系统。面对文件更新程序或数据库更新程序,功能测试只观察特定输入所导致的数据变化。 一旦功能测试者或最终用户找到软件中的一只臭虫,要除掉它至少需要做两件事。 当然你必须修改代码,才得以排除错误,但你还应该添加一个单元测试,让它揭发这只臭虫。事实上,每当收到臭虫提报(bug report),我都首先编写一个单元测试,使这只臭虫浮现。如果需要缩小臭虫出没范围,或如果出现其他相关失败(failures),我就会编写不只一个测试。我使用单元测试来帮助我盯住臭虫,并确保我的单元测 试不会有类似的漏网之……呃……臭虫。 TIP:每当你接获臭虫提报(bug report),请先撰写一个单元测试来揭发这只臭虫。 JUnit框架被设计用来编写单元测试。功能测试往往以其他工具辅助进行,例如某些拥有GUI (图形用户界面)的测试工具,然而通常你还得撰写一些与你的应用程序息息相关的测试工具,使能够比单纯使用GUI scripts (脚本语言)更轻松地管理 test cases (测试用例)。你也可以运用JUnit来执行功能测试,但这通常不是最有效的形式。当我要进行重构时,我倚赖程序员的好朋友:单元测试。