写在开头

虽然我的研究生科研方向是软件工程,但自己对测试的认识几乎为零。平时听大家在组会上的研究内容,以为测试工作就是将写好的代码丢给一个测试工具(不需要自己想测试用例),全自动完成并给出测试报告。然而前两天偶然接触JUnit,我才发现这么牛的单元测试工具还是需要自己写测试用例(理想和现实的差距),它只让自己少写了个main(第一反应)。今天照着csdn上的几篇博文尝试在eclipse中使用JUnit4,果然是我的菜。所以花点时间把JUnit的基本用法整理一下,记录在自己的博客里。以下是本文结构:

JUnit介绍

JUnit是一个开源的Java单元测试框架,由Erich GammaKent Beck建立。JUnit测试是白盒测试,即所谓的程序员测试(怪不得是我的菜)。这里是JUnit项目主页。目前JUnit的版本达到v4.12,本文使用的也是eclipse附带的JUnit4。

JUnit特性

  • 提供的API可以让码农写出测试结果明确的可重用单元测试用例
  • 提供三种方式来显示测试结果,并且可以扩展
  • 提供了单元测试用例成批运行的功能
  • 整个框架设计良好,易扩展

JUnit优点

  • 免费且开源,所以不需要购买(和破解
  • 将开发代码和测试代码分开,代码更加清晰易懂
  • 自动判断测试结果
  • 测试更加可靠
    • 原来自己一直用main()测试,但它一般是待测试类的一部分,具有访问私有成员和方法的权限
    • JUnit作为外部测试工具,具有的访问权限和其它外部程序相同
  • 对于极限编程、重构而言
    • 在编写代码前先写测试,强制自己确定代码的功能和逻辑
    • 测试和编码都是增量式,减小回归错误的纠错难度

这些功能都是针对单元测试的弊病,所以自己用起来会比较顺手。

JUnit使用

下面将以一些简单的单元测试演示如何使用JUnit,并进一步说明JUnit的用法和各种功能。本文演示环境是win8,eclipse Luna,JDK 1.8, JUnit4 Test。

JUnit简单上手

首先写一个待测试的工程,这里编写了一个Calculator类,这是一个简单实现四则运算以及平方开方的计算器类。然后对这些功能进行单元测试。这个类并不是完全正确的,这里故意保留了一些Bug用于测试,在代码中都有说明。该类代码如下:

package main;

public class Calculator {
    private static int result; // 静态变量,用于存储运行结果
    public void add(int n) {
        result = result + n;
    }
    public void substract(int n) {
        result = result - 1; // bug,正确的应该是result=result-n
    }
    public void multiply(int n) {
        // 该方法尚未写好
    }
    public void divide(int n) {
        result = result / n;
    }
    public void square(int n) {
        result = n * n;
    }
    public void squareRoot(int n) {
        for (;;); // bug,死循环
    }
    public void clear() {
        result = 0; // 将结果清零,以便进行下一次运算
    }
    public int getResult() {
        return result;
    }
}

接下来将JUnit单元测试库引入该项目:右击项目->点击属性->点击Java Build Path->选择Libraries选项卡->点击Add Library…->选择JUnit并点击Next->选择JUnit 4并点击Finish即可。至此JUnit4 Library被包含在待测试项目中。

然后生成JUnit测试框架,首先在工程中新建一个package:test,并在该package中新建一个JUnit Test Case,如下图所示:

配置JUnit Test Case

点击Next,eclipse会自动列出Calculator类中包含的方法,需要选择待测试的函数。这里选择加、减、乘、除4个函数,如图所示:

选择待测试方法

点击Finish,eclipse将自动生成一个新类CalculatorTest,里面包含一些空的测试方法。接下来填充这些测试方法以得到有效的测试用例。下面是演示测试用例代码:

package test;

import static org.junit.Assert.*;
import main.Calculator;

import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import java.lang.Exception;

public class CalculatorTest {

    private static Calculator calculator = new Calculator();

    @Before
    public void setUp() throws Exception {
        calculator.clear();
    }

    @Test
    public void testAdd() {
        calculator.add(2);
        calculator.add(3);
        assertEquals(5, calculator.getResult());
    }

    @Test
    public void testSubstract() {
        calculator.add(10);
        calculator.substract(2);
        assertEquals(8, calculator.getResult());
    }

    @Ignore("Multiply() not yet implemented")
    @Test
    public void testMultiply() {
        fail("Not yet implemented");
    }

    @Test
    public void testDivide() {
        calculator.add(8);
        calculator.divide(2);
        assertEquals(4, calculator.getResult());
    }
}

到这里就可以运行测试代码了。对CalculatorTest右击,选择Run As -> JUnit Test来运行测试,结果如下图所示:

简单测试的结果

图中测试结果表示共执行4个测试,其中1个测试被忽略,还有1个测试失败,失败原因是期望结果是8,而运行结果是9。整个结果简单明了,十分顺畅。接下来将详细说明JUnit用法和功能。

JUnit基本用法

前一节主要展示了JUnit的简单Demo,本节将对JUnit的基本用法进行详解,从而有更深的了解。

  1. 包含必要的Package

    在测试类中用到了JUnit4框架,自然要把相应的Package包含进来。最主要的一个Package是org.junit.*。还有一句话非常重要"import static org.junit.Assert.*",在测试的时候使用的一系列assertEquals方法就来自这个包。这里需要注意该Package是一个静态包含(static),也就是说assertEquals是Assert类中的一系列的静态方法,一般的使用方式是Assert.assertEquals(),但是使用了静态包含后,前面的类名就可以省略了,使用起来更加的方便。

  2. 测试类的声明

    从前一节的Demo可以发现测试类是一个独立的类,没有任何父类。测试类的名字也可以任意命名,没有任何局限性(与JUnit之前的版本的区别)。所以不能通过类的声明来判断它是不是一个测试类,它与普通类的区别在于它内部方法的声明。

  3. 创建一个待测试的对象

    需要测试哪个类,那首先需要创建一个该类的对象。如前一节中的代码:

    private static Calculator calculator = new Calculator();
    

    为了测试Calculator类,必须创建一个calculator对象。

  4. 测试方法的声明

    测试类中的方法不都是用于测试的,故须要使用“标注(Annotation)”来指明哪些是测试方法。在前一节Demo中可以看到,某些方法的前有@Before、@Test、@Ignore等字样,这些以"@"作为开头的内容就是标注。这些标注是JUnit4自定义的,熟练掌握这些标注对学习使用JUnit4很必要,本文最后会对JUnit4的标注进行总结。

  5. 编写一个简单的测试方法

    首先需要在方法的前面使用@Test标注,以表明这是一个测试方法。对于方法的声明也有如下要求:方法名字没有限制,但返回值必须为void,而且不能有参数,否则会在运行时抛出异常。至于方法内该写什么,就根据测试者自行设计的测试用例来填充。例如:

    @Test
    public void testAdd(){
        calculator.add(2);
        calculator.add(3);
        assertEquals(5, calculator.getResult());
    }
    

    上面的测试方法目的是测试Calculator类的加法功能是否正确,就在测试方法中调用了几次add函数,再提供预期结果并计算实际结果。JUnit会自动进行测试并把测试结果反馈给用户。

  6. 测试时忽略某些尚未完成的方法

    如果你在写程序前做了很好的规划,那么每个方法的功能都应该是确定的。因此,即使该方法尚未完成,它的具体功能也是确定的,这也就意味着你可以为它编写测试用例。但如果你已经把该方法的测试用例写完,但该方法尚未完成,那么测试的时候一定是“失败”。这种失败和真正的失败是有区别的,因此JUnit提供一种方法来区别它们,那就是在这种测试函数的前面加上@Ignore标注,这个标注的含义就是“某些方法尚未完成,暂不参与此次测试”。这样的话测试结果就会提示你有几个测试被忽略,而不是失败。一旦你完成了相应函数,只需要把@Ignore标注删去,就可以进行正常的测试。

  7. Fixture

    Fixture的含义是“在某些阶段必然被调用的代码”。比如前面的测试,由于只声明了一个Calculator对象,它的初始值是0,但是测试加法操作后,它的值就不是0了;接下来测试减法操作,就必然要考虑上次加法操作的结果。这绝对是一个很糟糕的设计!每一个测试都应该是独立的,相互之间没有任何耦合度。因此很有必要在执行每一个测试方法前,对测试对象进行一个“复原”操作,以消除其它测试造成的影响。“在任何一个测试执行之前必须执行的代码”就是一个Fixture,用@Before来标注它,如前一节的代码所示:

    @Before
    public void setUp() throws Exception {
        calculator.clear();
    }
    

    这里不再需要@Test标注,因为这不是Test,而是Fixture。同理,“在任何测试执行之后需要进行的收尾工作”也是一个Fixture,使用@After来标注。

JUnit高阶用法

前两节讲解的JUnit使用可以应付简单的单元测试需求,同时JUnit提供了更加高级的用法以解决复杂的测试需求。下面将简述相关功能。

  1. 高级Fixture

    前一节最后介绍了两个Fixture标注,分别是@Before和@After。接下来看看它们是否适合完成如下功能:有一个类是负责对大文件(超过500兆)进行读写(非常耗时),它的每一个方法都是对文件进行操作。如果使用@Before和@After,那么每次测试都要读取一次文件,效率极差。因此在所有测试开始时读一次文件,所有测试结束时释放文件,才是有效的解决方案。 JUnit给出了@BeforeClass和@AfterClass两个Fixture来实现这个功能。从名字上就可以看出,用这两个Fixture标注的函数,只在测试用例初始化时执行@BeforeClass方法,在测试用例执行结束时执行@AfterClass方法。另外,这里要注意一点,每个测试类只能有一个方法被标注为@BeforeClass或@AfterClass,并且该方法必须是public和static的。

  2. 限时测试

    在JUnit Demo中Calculator类的求平方根的函数存在Bug,是个死循环:

    public void squareRoot(int n) {
        for (;;); // bug,死循环
    }
    

    如果在测试过程中遇到死循环将会非常糟糕。对于那些逻辑复杂、循环嵌套比较深的程序,很有可能出现死循环,因此一定要采取一些预防措施。限时测试是一个很好的解决方案。测试者给这些测试函数设定一个执行时间,超过了这个时间,它们就会被强行终止,并且系统还会汇报该函数结束的原因是因为超时,这样会相对容易的解决死循环bug。要实现这一功能,只需要给@Test标注加一个参数即可,如下所示:

    @Test(timeout = 1000)
    public void squareRoot() {
        calculator.squareRoot( 4 );
        assertEquals( 2 , calculator.getResult());
    }
    

    Timeout参数表示设定的执行时间,单位为毫秒。

  3. 异常测试

    Java中的异常处理是一个重点,因此代码中经常会出现一些需要抛出异常的函数。如果一个函数应该抛出异常,但是它没抛出,这也是一种bug。JUnit也考虑到了这一点,并提供了相应的测试办法。例如,前文中Calculator类有除法功能,如果除数是一个0,那么必然要抛出“除0异常”。下面的代码就是检测是否抛出该异常:

    @Test(expected = ArithmeticException.class)
    public void divideByZero() {
        calculator.divide(0);
    }
    

    如上述代码所示,异常测试需要使用@Test标注的expected属性,将待检验的异常传递给它,这样JUnit框架就能自动检测是否抛出了指定的异常。

  4. Runner

    测试类并没有main入口,那测试代码是如何被运行的呢?答案就是——Runner。JUnit提供了很多Runner,它们负责调用测试代码,不同的Runner有各自的特殊功能,测试者只要根据需求选用不同的Runner来运行测试代码即可。当不指定Runner时,JUnit会用默认的Runner来运行测试代码。换句话说,下面两段代码含义是完全一样的:

    import org.junit.internal.runners.TestClassRunner;
    import org.junit.runner.RunWith;
    
    @RunWith(TestClassRunner.class)
    public class CalculatorTest {
        ...
    }
    
    // 使用了系统默认的TestClassRunner,与上面代码完全一样 
    public class CalculatorTest {
        ...
    }
    

    从上述例子可以看出,要想指定一个Runner,需要使用@RunWith标注,并且把所指定的Runner作为参数传递给它。另外要注意的是,@RunWith是用来修饰类的,而不是用来修饰函数的。只要对一个类指定了Runner,那么这个类中的所有函数都被这个Runner来调用。下一小节将展示其它Runner的特有功能。

  5. 参数化测试

    在很多情况下,测试用例需要不同程度的覆盖待测试方法,这也就意味着对于同一个方法需要编写多个测试用例。如果前面介绍的方法来编写测试将是一件重复耗时的工作,比如在Calculator类中测试square函数,分别使用正数、0、负数来测试。如下所示:

    import org.junit.AfterClass;
    import org.junit.Before;
    import org.junit.BeforeClass;
    import org.junit.Test;
    import static org.junit.Assert.*;
    
    public class AdvancedTest {
        private static Calculator calculator  new Calculator();
    
        @Before
        public void clearCalculator() {
            calculator.clear();
        }
    
        @Test
        public void square1() {
            calculator.square(2);
            assertEquals(4, calculator.getResult());
        }
    
        @Test
        public void square2() {
            calculator.square(0);
            assertEquals(0, calculator.getResult());
        }
    
        @Test
        public void square3() {
            calculator.square(-3);
            assertEquals( 9 , calculator.getResult());
        }
    }
    

    为了简化类似的测试,JUnit4提供了“参数化测试”,只写一个测试函数,把若干测试用例作为参数传递进去,一次性完成测试。代码如下:

    import static org.junit.Assert.assertEquals;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.junit.runners.Parameterized;
    import org.junit.runners.Parameterized.Parameters;
    import java.util.Arrays;
    import java.util.Collection;
    
    @RunWith(Parameterized.class)
    public class SquareTest {
        private static Calculator calculator = new Calculator();
        private int param;
        private int result;
    
        @Parameters
        public static Collection data() {
            return Arrays.asList(new Object[][] {
                {2, 4},
                {0, 0},
                {3, 9},
            });
        }
    
        // 构造函数,对变量进行初始化
        public SquareTest(int param, int result) {
            this.param = param;
            this.result = result;
        }
    
        @Test
        public void square() {
            calculator.square(param);
            assertEquals(result, calculator.getResult());
        } 
    }
    

    这里对上述代码进行简要分析。首先,参数化测试的Runner与JUnit默认Runner不同,因此需要为这种测试专门生成一个新的类(原因见上一小节)。此例定义SquareTest类作为新的测试类,并为该类指定了参数化测试的Runner。接下来,定义一个待测试的类,并且定义两个变量,一个用于存放参数,一个用于存放预期结果。然后,定义测试数据的集合,也就是上述的data()方法,该方法可以任意命名,但是必须使用@Parameters标注进行修饰。该方法需注意其中的数据,是一个二维数组,数据两两一组,分别是参数和预期结果。这两个数据的顺序可以自己设定,与构造函数的顺序保持一致即可。之后是构造函数,其功能就是对先前定义的两个参数进行初始化。构造函数的参数要和数据集合的顺序保持一致。最后就是写测试用例,除参数部分用前面定义的变量指定,其它与前面介绍的方法相同,不再赘述。

  6. 打包测试

    在项目中只写一个测试类是不可能的,但根据前面的介绍,这些测试类需要一个一个的执行,这样将会非常繁琐。因此,JUnit也提供了打包测试的功能,将所有需要运行的测试类集中起来,一次性运行完毕,大大减少了测试工作量。具体代码如下:

    import org.junit.runner.RunWith;
    import org.junit.runners.Suite;
    
    @RunWith(Suite.class)
    @Suite.SuiteClasses({
        CalculatorTest.class,
        SquareTest.class
    })
    public class AllCalculatorTests {
    }
    

    从上述代码可以发现,该功能也需使用一种特殊的Runner,因此需要向@RunWith标注传递一个参数Suite.class。同时需要另外一个标注@Suite.SuiteClasses,以表明这个类是一个打包测试类,并把需要打包的类作为参数传递给该标注即可。这两个标注可以完整表达所有含义,因此下面的类已经无关紧要,随便起一个类名,内容全部为空既可。

至此是我所学习和参考的JUnit常见用法。下面对JUnit的功能进行总结,以方便以后的查询。

JUnit功能总结

JUnit面向测试方法的Annotations

此处汇总了前文中涉及到的JUnit标注(Annotation),其中也包含前文介绍的4个Fixture Annotations。

Annotations 描述
@Test 表示方法是一个测试方法
@Test(expected = Exception.class) 表示预期会抛出Exception.class的异常
@Test(timeout = 100) 表示测试方法预期执行不超过100毫秒
@Ignore 表示测试方法被忽略,一般用于待测试方法功能尚未实现的情况
@Before 表示该方法在每个测试方法前运行,一般用于初始化之类的操作
@After 表示该方法在每个测试方法后运行,一般用于资源释放类操作
@BeforeClass 表示该方法在所有方法之前执行,并且只执行一次
@AfterClass 表示该方法在所有方法之后执行,并且只执行一次

JUnit内建的Runner

Runner是JUnit运行的基础,在前面的介绍中我们也可以发现其重要性。当然在使用特殊Runner时记得添加@RunWith Annotation。下面将汇总JUnit提供的Runner以备查询。

Runner 作用
BlockJUnit4ClassRunner 该runner是JUnit4的默认runner
JUnit4 该runner总会调用JUnit4的默认runner,是默认runner的别称
Parameterized 该runner用于参数化测试,具体使用见前文
Suite 该runner用于组合测试,具体使用见前文
Categories 该runner用于选择性执行,具体使用见Categories

另外JUnit还有一些处于测试的runner和第三方runner,如果有必要,可以查看JUnit的Github主页

结束语

本文主要涉及的是基于eclipse的JUnit4使用,其它平台的使用应该大同小异。通过这段时间的学习和码字,对JUnit4越发钦佩,希望再接再厉,在平时的工作学习中有效利用这一利器。最后我要感谢参考博文的博主!

参考资料

[1] 在Eclipse中使用JUnit4进行单元测试

[2] 使用JUnit进行单元测试

[3] JUnit官网

[4] JUnit Github主页

Comments