junit4入门

转载自:http://www.ibm.com/developerworks/cn/java/j-junit4.html

JUnit是Java?语言事实上的标准单元测试库。JUnit4是该库三年以来最具里程碑意义的一次发布。它的新特性主要是通过采用Java5中的标记(annotation)而不是利用子类、反射或命名机制来识别测试,从而简化测试。在本文中,执着的代码测试人员ElliotteHarold以JUnit4为例,详细介绍了如何在自己的工作中使用这个新框架。注意,本文假设读者具有JUnit的使用经验。

JUnit由KentBeck和ErichGamma开发,几乎毫无疑问是迄今所开发的最重要的第三方Java库。正如MartinFowler所说,“在软件开发领域,从来就没有如此少的代码起到了如此重要的作用”。JUnit引导并促进了测试的盛行。由于JUnit,Java代码变得更健壮,更可靠,bug也比以前更少。JUnit(它本身的灵感来自Smalltalk的SUnit)衍生了许多xUnit工具,将单元测试的优势应用于各种语言。nUnit(.NET)、pyUnit(Python)、CppUnit(C++)、dUnit(Delphi)以及其他工具,影响了各种平台和语言上的程序员的测试工作。

然而,JUnit仅仅是一个工具而已。真正的优势来自于JUnit所采用的思想和技术,而不是框架本身。单元测试、测试先行的编程和测试驱动的开发并非都要在JUnit中实现,任何比较GUI的编程都必须用Swing来完成。JUnit本身的最后一次更新差不多是三年以前了。尽管它被证明比大多数框架更健壮、更持久,但是也发现了bug;而更重要的是,Java不断在发展。Java语言现在支持泛型、枚举、可变长度参数列表和注释,这些特性为可重用的框架设计带来了新的可能。

JUnit的停滞不前并没有被那些想要废弃它的程序员所打败。挑战者包括BillVenners的ArtimaSuiteRunner以及CedricBeust的TestNG等。这些库有一些可圈可点的特性,但是都没有达到JUnit的知名度和市场占有份额。它们都没有在诸如Ant、Maven或Eclipse之类的产品中具有广泛的开箱即用支持。所以Beck和Gamma着手开发了一个新版本的JUnit,它利用Java5的新特性(尤其是注释)的优势,使得单元测试比起用最初的JUnit来说更加简单。用Beck的话来说,“JUnit4的主题是通过进一步简化JUnit,鼓励更多的开发人员编写更多的测试。”JUnit4尽管保持了与现有JUnit3.8测试套件的向后兼容,但是它仍然承诺是自JUnit1.0以来Java单元测试方面最重大的改进。

注意:该框架的改进是相当前沿的。尽管JUnit4的大轮廓很清晰,但是其细节仍然可以改变。这意味着本文是对JUnit4抢先看,而不是它的最终效果。

测试方法

以前所有版本的JUnit都使用命名约定和反射来定位测试。例如,下面的代码测试1+1等于2:

import junit.framework.TestCase;
public class AdditionTest extends TestCase {
  private int x = 1;
  private int y = 1;
  
  public void testAddition() {
    int z = x + y;
    assertEquals(2, z);
  }
}

而在JUnit4中,测试是由@Test注释来识别的,如下所示:

import org.junit.Test;
import junit.framework.TestCase;
public class AdditionTest extends TestCase {
  private int x = 1;
  private int y = 1;
  
  @Test public void testAddition() {
    int z = x + y;
    assertEquals(2, z);
  }
}

使用注释的优点是不再需要将所有的方法命名为testFoo()、testBar(),等等。例如,下面的方法也可以工作:

import org.junit.Test;
import junit.framework.TestCase;
public class AdditionTest extends TestCase {
  private int x = 1;
  private int y = 1;
  
  @Test public void additionTest() {
    int z = x + y;
    assertEquals(2, z);
  }
}

下面这个方法也同样能够工作:

import org.junit.Test;
import junit.framework.TestCase;
public class AdditionTest extends TestCase {
  private int x = 1;
  private int y = 1;
  
  @Test public void addition() {
    int z = x + y;
    assertEquals(2, z);
  }
}

这允许您遵循最适合您的应用程序的命名约定。例如,我介绍的一些例子采用的约定是,测试类对其测试方法使用与被测试的类相同的名称。例如,List.contains()由ListTest.contains()测试,List.add()由ListTest.addAll()测试,等等。

TestCase类仍然可以工作,但是您不再需要扩展它了。只要您用@Test来注释测试方法,就可以将测试方法放到任何类中。但是您需要导入junit.Assert类以访问各种assert方法,如下所示:

import org.junit.Assert;
public class AdditionTest {
  private int x = 1;
  private int y = 1;
  
  @Test public void addition() {
    int z = x + y;
    Assert.assertEquals(2, z);
  }
}

您也可以使用JDK5中新特性(staticimport),使得与以前版本一样简单:

import static org.junit.Assert.assertEquals;
public class AdditionTest {
  private int x = 1;
  private int y = 1;
  
  @Test public void addition() {
    int z = x + y;
    assertEquals(2, z);
  }
}

这种方法使得测试受保护的方法非常容易,因为测试案例类现在可以扩展包含受保护方法的类了。

SetUp和TearDown

JUnit3测试运行程序(testrunner)会在运行每个测试之前自动调用setUp()方法。该方法一般会初始化字段,打开日志记录,重置环境变量,等等。例如,下面是摘自XOM的XSLTransformTest中的setUp()方法:

protected void setUp() {
        
    System.setErr(new PrintStream(new ByteArrayOutputStream()));
        
    inputDir = new File("data");
    inputDir = new File(inputDir, "xslt");
    inputDir = new File(inputDir, "input");
        
}

在JUnit4中,您仍然可以在每个测试方法运行之前初始化字段和配置环境。然而,完成这些操作的方法不再需要叫做setUp(),只要用@Before注释来指示即可,如下所示:

@Before protected void initialize() {
        
    System.setErr(new PrintStream(new ByteArrayOutputStream()));
        
    inputDir = new File("data");
    inputDir = new File(inputDir, "xslt");
    inputDir = new File(inputDir, "input");
        
}

甚至可以用@Before来注释多个方法,这些方法都在每个测试之前运行:

@Before protected void findTestDataDirectory() {
    inputDir = new File("data");
    inputDir = new File(inputDir, "xslt");
    inputDir = new File(inputDir, "input");
}
    
 @Before protected void redirectStderr() {
    System.setErr(new PrintStream(new ByteArrayOutputStream()));
}

清除方法与此类似。在JUnit3中,您使用tearDown()方法,该方法类似于我在XOM中为消耗大量内存的测试所使用的方法:

protected void tearDown() {
  doc = null;
  System.gc();   
}

对于JUnit4,我可以给它取一个更自然的名称,并用@After注释它:

@After protected void disposeDocument() {
  doc = null;
  System.gc();   
}

与@Before一样,也可以用@After来注释多个清除方法,这些方法都在每个测试之后运行。

最后,您不再需要在超类中显式调用初始化和清除方法,只要它们不被覆盖即可,测试运行程序将根据需要自动为您调用这些方法。超类中的@Before方法在子类中的@Before方法之前被调用(这反映了构造函数调用的顺序)。@After方法以反方向运行:子类中的方法在超类中的方法之前被调用。否则,多个@Before或@After方法的相对顺序就得不到保证。

套件范围的初始化

JUnit4也引入了一个JUnit3中没有的新特性:类范围的setUp()和tearDown()方法。任何用@BeforeClass注释的方法都将在该类中的测试方法运行之前刚好运行一次,而任何用@AfterClass注释的方法都将在该类中的所有测试都运行之后刚好运行一次。

例如,假设类中的每个测试都使用一个数据库连接、一个网络连接、一个非常大的数据结构,或者还有一些对于初始化和事情安排来说比较昂贵的其他资源。不要在每个测试之前都重新创建它,您可以创建它一次,并还原它一次。该方法将使得有些测试案例运行起来快得多。例如,当我测试调用第三方库的代码中的错误处理时,我通常喜欢在测试开始之前重定向System.err,以便输出不被预期的错误消息打乱。然后我在测试结束后还原它,如下所示:

// This class tests a lot of error conditions, which
// Xalan annoyingly logs to System.err. This hides System.err 
// before each test and restores it after each test.
private PrintStream systemErr;
    
@BeforeClass protected void redirectStderr() {
    systemErr = System.err; // Hold on to the original value
    System.setErr(new PrintStream(new ByteArrayOutputStream()));
}
    
@AfterClass protected void tearDown() {
    // restore the original value
    System.setErr(systemErr);
}

没有必要在每个测试之前和之后都这样做。但是一定要小心对待这个特性。它有可能会违反测试的独立性,并引入非预期的混乱。如果一个测试在某种程度上改变了@BeforeClass所初始化的一个对象,那么它有可能会影响其他测试的结果。它有可能在测试套件中引入顺序依赖,并隐藏bug。与任何优化一样,只在剖析和基准测试证明您具有实际的问题之后才实现这一点。这就是说,我看到了不止一个测试套件运行时间如此之长,以至不能像它所需要的那样经常运行,尤其是那些需要建立很多网络和数据库连接的测试。(例如,LimeWire测试套件运行时间超过两小时。)要加快这些测试套件,以便程序员可以更加经常地运行它们,您可以做的就是减少bug。

测试异常

异常测试是JUnit4中的最大改进。旧式的异常测试是在抛出异常的代码中放入try块,然后在try块的末尾加入一个fail()语句。例如,该方法测试被零除抛出一个

ArithmeticException:

public void testDivisionByZero() {
    
    try {
        int n = 2 / 0;
        fail("Divided by zero!");
    }
    catch (ArithmeticException success) {
        assertNotNull(success.getMessage());
    }
    
}

该方法不仅难看,而且试图挑战代码覆盖工具,因为不管测试是通过还是失败,总有一些代码不被执行。在JUnit4中,您现在可以编写抛出异常的代码,并使用注释来声明该异常是预期的:

@Test(expected=ArithmeticException.class) 
  public void divideByZero() {
    int n = 2 / 0;
}

如果该异常没有抛出(或者抛出了一个不同的异常),那么测试就将失败。但是如果您想要测试异常的详细消息或其他属性,则仍然需要使用旧式的try-catch样式。

被忽略的测试

也许您有一个测试运行的时间非常地长。不是说这个测试应该运行得更快,而是说它所做的工作从根本上比较复杂或缓慢。需要访问远程网络服务器的测试通常都属于这一类。如果您不在做可能会中断该类测试的事情,那么您可能想要跳过运行时间长的测试方法,以缩短编译-测试-调试周期。或者也许是一个因为超出您的控制范围的原因而失败的测试。例如,W3CXInclude测试套件测试Java还不支持的一些Unicode编码的自动识别。不必老是被迫盯住那些红色波浪线,这类测试可以被注释为@Ignore,如下所示:

// Java doesn't yet support 
// the UTF-32BE and UTF32LE encodings
    @Ignore public void testUTF32BE() 
      throws ParsingException, IOException, XIncludeException {
      
        File input = new File(
          "data/xinclude/input/UTF32BE.xml"
        );
        Document doc = builder.build(input);
        Document result = XIncluder.resolve(doc);
        Document expectedResult = builder.build(
          new File(outputDir, "UTF32BE.xml")
        );
        assertEquals(expectedResult, result);
                
    }

测试运行程序将不运行这些测试,但是它会指出这些测试被跳过了。例如,当使用文本界面时,会输出一个“I”(代表ignore),而不是为通过的测试输出所经历的时间,也不是为失败的测试输出“E”:

$ java -classpath .:junit.jar org.junit.runner.JUnitCore 
  nu.xom.tests.XIncludeTest
JUnit version 4.0rc1
.....I..
Time: 1.149
OK (7 tests)

但是一定要小心。最初编写这些测试可能有一定的原因。如果永远忽略这些测试,那么它们期望测试的代码可能会中断,并且这样的中断可能不能被检测到。忽略测试只是一个权宜之计,不是任何问题的真正解决方案。

时间测试

测试性能是单元测试最为痛苦的方面之一。JUnit4没有完全解决这个问题,但是它对这个问题有所帮助。测试可以用一个超时参数来注释。如果测试运行的时间超过指定的毫秒数,则测试失败。例如,如果测试花费超过半秒时间去查找以前设置的一个文档中的所有元素,那么该测试失败:

@Test(timeout=500) public void retrieveAllElementsInDocument() {
    doc.query("//*");
}

除了简单的基准测试之外,时间测试也对网络测试很有用。在一个测试试图连接到的远程主机或数据库宕机或变慢时,您可以忽略该测试,以便不阻塞所有其他的测试。好的测试套件执行得足够快,以至程序员可以在每个测试发生重大变化之后运行这些测试,有可能一天运行几十次。设置一个超时使得这一点更加可行。例如,如果解析http://www.ibiblio.org/xml花费了超过2秒,那么下面的测试就会超时:

@Test(timeout=2000) 
  public void remoteBaseRelativeResolutionWithDirectory()
   throws IOException, ParsingException {
      builder.build("http://www.ibiblio.org/xml");
  }

新的断言

JUnit4为比较数组添加了两个assert()方法:

public static void assertEquals(Object[] expected, Object[] actual)
public static void assertEquals(String message, Object[] expected, 
Object[] actual)

这两个方法以最直接的方式比较数组:如果数组长度相同,且每个对应的元素相同,则两个数组相等,否则不相等。数组为空的情况也作了考虑。

需要补充的地方

JUnit4基本上是一个新框架,而不是旧框架的升级版本。JUnit3开发人员可能会找到一些原来没有的特性。

最明显的删节就是GUI测试运行程序。如果您想在测试通过时看到赏心悦目的绿色波浪线,或者在测试失败时看到令人焦虑的红色波浪线,那么您需要一个具有集成JUnit支持的IDE,比如Eclipse。不管是Swing还是AWT测试运行程序都不会被升级或捆绑到JUnit4中。

下一个惊喜是,失败(assert方法检测到的预期的错误)与错误(异常指出的非预期的错误)之间不再有任何差别。尽管JUnit3测试运行程序仍然可以区别这些情况,而JUnit4运行程序将不再能够区分。

最后,JUnit4没有suite()方法,这些方法用于从多个测试类构建一个测试套件。相反,可变长参数列表用于允许将不确定数量的测试传递给测试运行程序。

我对消除了GUI测试运行程序并不感到太高兴,但是其他更改似乎有可能增加JUnit的简单性。只要考虑有多少文档和FAQ当前专门用于解释这几点,然后考虑对于JUnit4,您不再需要解释这几点了。

编译和运行JUnit4

当前,还没有JUnit4的库版本。如果您想要体验新的版本,那么您需要从SourceForge上的CVS知识库获取它。分支(branch)是“Version4”(参见参考资料)。注意,很多的文档没有升级,仍然是指以旧式的3.x方式做事。Java5对于编译JUnit4是必需的,因为JUnit4大量用到注释、泛型以及Java5语言级的其他特性。

自JUnit3以来,从命令行运行测试的语法发生了一点变化。您现在使用org.junit.runner.JUnitCore类:

$ java -classpath .:junit.jar org.junit.runner.JUnitCore 
  TestA TestB TestC...
JUnit version 4.0rc1
Time: 0.003
OK (0 tests)

兼容性

Beck和Gamma努力维持向前和向后兼容。JUnit4测试运行程序可以运行JUnit3测试,不用做任何更改。只要将您想要运行的每个测试的全限定类名传递给测试运行程序,就像针对JUnit4测试一样。运行程序足够智能,可以分辨出哪个测试类依赖于哪个版本的JUnit,并适当地调用它。

向后兼容要困难一些,但是也可以在JUnit3测试运行程序中运行JUnit4测试。这一点很重要,所以诸如Eclipse之类具有集成JUnit支持的工具可以处理JUnit4,而不需要更新。为了使JUnit4测试可以运行在JUnit3环境中,可以将它们包装在JUnit4TestAdapter中。将下面的方法添加到您的JUnit4测试类中应该就足够了:

public static junit.framework.Test suite() {
  return new JUnit4TestAdapter(AssertionTest.class);    
}

但是由于Java比较多变,所以JUnit4一点都不向后兼容。JUnit4完全依赖于Java5特性。对于Java1.4或更早版本,它将不会编译或运行。

相关推荐