JUnit4 入门:掌握 Java 单元测试的艺术

JUnit 是一个开放源代码的 Java 测试框架,用于编写和运行可重复的测试。他是用于单元测试框架体系 xUnit 的一个实例(用于 java 语言)。

为什么使用 Junit

我们以前测试一个类的步骤:

  1. 新建一个 test 类
  2. 创建 main() 方法
  3. 在 main 类 new 一个我们要测试的类的实例
  4. 然后调用这个类的方法,输出一个结果

当测试的类有多个方法时,我们必须调用所有的方法,为了不让上一次的方法调用对下一次的调用产生影响,我们会在 new 一个实例出来,或者将上一次的代码注释掉.
则将造成整个测试代码的混乱.
这个时候我们希望如果可以有多个 mian() 方法,每个 main() 方法内只调用一个需要测试的类的方法,
这样显得调理清晰.但是这是不可能的,一个程序只能有一个入口

这个时候,Junit 站了出来,它大声的说它可以做到.

怎么使用 Junit

主要步骤:

  1. 新建一个 java 项目
  2. 在 src 下新建一个 util 包,编写一个普通的类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 对名称,地址等字符串格式的内容进行格式检查
* 或者格式化的工具类
*/
public class WordDeanUtil {
/**
* 将Java对象名称(每个单词的头字符大写)按照
* 数据库命名的习惯进行格式化
* 格式化后的数据为小写字母,并且使用下划线分割命名单词
* 例如:employeeInfo-->employee_info
*
* @param name Java对象名称
*/
public static String wordFormat4DB(String name){
//使用给定的正则表达式创建Pattern对象(将给定的正则表达式编译到模式中。)
Pattern p = Pattern.compile("[A-Z]");
//创建 字符串和模式匹配的匹配器
Matcher m = p.matcher(name);
StringBuffer sb = new StringBuffer();
//拿着name中的字符一个一个的去和正则表达式对比,成功返回true
while(m.find()){
//找大写字母
m.appendReplacement(sb, "_" + m.group());
}
return m.appendTail(sb).toString().toLowerCase();
}
}
  1. 在 src 下新建一个 tests 文件夹,将它设置为测试专用文件夹
    • 在工程名上按 f4
    • 找到 Modules–>Sources
    • 找到 tests 文件夹,然后 Mark as Tests

20241229154732_szYRVb9s.webp

  1. 选中我们要测试的类的类名,然后 Ctrl+shift+t –> Create new test

  2. 选择 Junit4,然后选择要测试的类的方法 (method),setUp 和 tearDown 后面再介绍
    20241229154732_CjwnCDQD.webp

  3. 点击 OK 后,如果前面的 test 测试文件夹没有出错的话,会在 tests 文件夹下生成一个包,这个包和我们要测试的类的包一样,还有一个以测试类名 +Test 的类 (不同的 IDE 有不同的规则,Myeclipse 就是在前面加 test 的),我们主要在这个类中操作
    (单元测试代码和被测试代码使用一样的包,不同的目录)

20241229154732_2bT5TNG7.webp

  1. 下面来写一个简单的测试方法
    测试方法书写规范: - 测试方法必须使用注解 org.junit.Test 修饰 - 测试方法必须使用 public void 修饰,而且不能带有任何参数 - 测试方法名一般以 test+ 被测试的方法名书写
    20241229154732_EF3hDbgY.webp

        - 说明:
            1. 我们只需要要这个测试方法当成一个 main() 方法,在这个方法里面书写我们以前在 main() 方法内写过的测试代码.
                - new 一个我们要测试的类的实例
                - 然后调用这个类的方法,输出一个结果
            2. 在这个例子中我们只有一个需要测试的方法,而且是静态的.所以直接就使用类名 + 方法名调用我们要测试的方法了
            3. `assertEquals("employee_info",reslut)` 的意思是第一个参数时我们能预测的想要的结果,第二个参数是我们要测试的方法返回的结果,如果这两个字符串相同,整个测试通过.
            4. 我们完全可以不使用 Junit 提供的这个方法
                ![20241229154732_cN5eJ3Gv.webp](https://cdn.dong4j.site/source/image/20241229154732_cN5eJ3Gv.webp)
    
            5. assertEquals 是由 JUnit 提供的一系列判断测试结果是否正确的静态断言方法(位于类 org.junit.Assert 中)之一
            6. Junit 给我们提供了大量的静态方法让我们编写少量的代码就可以完成测试.我们干嘛不用呢?
    

单元测试不是用来证明你是对的,而是为了证明你没有错
虽然上面的测试运行通过了,但是并不代表代码通过单元测试,因为单元测试不是证明你是对的而设计的,我们得想方设法来证明我们的代码没有错.
所有我们得考虑到所有得情况来证明我们得代码没有错误:

上一个测试的补充:

  1. 测试 null 时的处理情况
  2. 测试空字符串的处理情况
  3. 测试单首字母大写时的情况
  4. 测试多个相连字母大写时的情况

完成测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class WordDeanUtilTest {

@Before
public void setUp() throws Exception {
System.out.println("测试开始");
}

@After
public void tearDown() throws Exception {
System.out.println("测试结束");
}
@Test
public void testWordFormat4DB() {
String target = "employeeInfo";
String reslut = WordDeanUtil.wordFormat4DB(target);
assertEquals("employee_info", reslut);
}

//测试null时的处理情况
@Test
public void wordFormat4DBNull(){
String target = null;
String reslut = WordDeanUtil.wordFormat4DB(target);
assertNull(reslut);
}

//测试空字符串的处理情况
@Test
public void wordFormat4DBEmpty(){
String target = "";
String reslut = WordDeanUtil.wordFormat4DB(target);
assertEquals("", reslut);
}

//测试当首字母大写时的处理情况
@Test
public void wordFormat4DBBegin() {
String target = "EmployeeInfo";
String reslut = WordDeanUtil.wordFormat4DB(target);
assertEquals("employee_info", reslut);
}

//测试尾字母大写时的处理情况
@Test
public void wordFormat4DBEnd() {
String target = "employeeInfoA";
String reslut = WordDeanUtil.wordFormat4DB(target);
assertEquals("employee_info_a", reslut);
}

//测试多个项链字母大写时的处理情况
@Test
public void wordFormat4DBTogether() {
String target = "employeeAInfo";
String reslut = WordDeanUtil.wordFormat4DB(target);
assertEquals("employee_a_info", reslut);
}
}

再次运行上面的测试代码时,你会发现测试未通过
20241229154732_N6WdzrGS.webp

20241229154732_YxaXWhDE.webp

有一个空指针异常,由此可见我们的 wordFormat4DB() 方法没有对 null 做出处理
还有一个处理结果和我们预期的不一样,这就是一个 bug,被 Junit 找出来了

修改被测试的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class WordDeanUtil {
/**
* 将Java对象名称(每个单词的头字符大写)按照
* 数据库命名的习惯进行格式化
* 格式化后的数据为小写字母,并且使用下划线分割命名单词
* 例如:employeeInfo-->employee_info
*
* @param name Java对象名称
*/
public static String wordFormat4DB(String name){
//增加null验证
if(name == null){
return null;
}
//使用给定的正则表达式创建Pattern对象(将给定的正则表达式编译到模式中。)
Pattern p = Pattern.compile("[A-Z]");
//创建 字符串和模式匹配的匹配器
Matcher m = p.matcher(name);
StringBuffer sb = new StringBuffer();
//拿着name中的字符一个一个的去和正则表达式对比,成功返回true
while(m.find()){
//增加首字母大写验证
if(m.start() != 0){
m.appendReplacement(sb, ("_" + m.group()).toLowerCase());
}

}
return m.appendTail(sb).toString().toLowerCase();
}
}

再次运行,测试通过~~~~


Junit 深入理解

Fixture

当编写测试方法时,必须先初始化数据,每个测试方法都需要这么做,就会造成重复的代码,所以 Junit 提出了 Fixture 解决方案
Fixture:
整治执行一个或者多个测试方法时需要的一系列公共资源或者数据.

意思就是初始化多个测试方法都需要使用到的数据

设置 Fixture:

  1. 使用注解 org.junit.Before 修饰用于初始化的方法
  2. 使用注解 org.junit.After 修饰用于注销的方法
  3. 保证这两种方法都是用 public void 修饰,而且不能带任何参数

方法级别的 Fixture 设置方法:

1
2
3
4
5
6
//初始化方法
@Before
public void init(){...}
//注销方法
@After
public void destroy(){...}

每个测试方法执行之前,都会执行 init 方法;
测试方法执行完毕之后,都会执行 destroy() 方法;
这种方式保证了各个独立测试之间互不干扰,一面其他测试代码修改测试环境或者测试数据影响到其他测试代码的准确性

方法级别 Fixture 执行示意图
20241229154732_4JtLsecj.webp

下面是具体的测试结果:
20241229154732_7WIqoyMi.webp

跟描述的一样~~~~

但是这种方式效率低下,每个测试方法都要初始化一次,关闭一次,对数据库连接来说是一场噩梦;
而且对于不会发生变化的测试环境或者测试数据来说,是不会影响到执行结果的,页就没必要每次都初始化和销毁;
因此 Junit4 引入了 级别的 Fixture 设置方法:

  1. 使用注解 org.junit.BeforeClass 修饰用于初始化的方法
  2. 使用注解 org.junit.AfterClass 修饰用于注销的方法
  3. 保证这两种方法都是用 public static void 修饰,而且不能带任何参数

类级别的 Fixture 仅会在测试类中所有测试方法执行之前执行初始化,并且在全部测试方法测试完毕后执行注销方法

1
2
3
4
@BeforeClass
public void static init(){...}
@AfterClass
public void static destroy(){...}

下面是具体的测试结果:
20241229154732_SO1IaVeg.webp

异常和时间测试

注解 org.junit.Test 中有两个非常有用的参数: expected 和 timeout。

expected

代表测试方法期望抛出指定的异常,如果运行测试并没有抛出这个异常,则 JUnit 会认为这个测试没有通过。这为验
证被测试方法在错误的情况下是否会抛出预定的异常提供了便利。
举例来说,方法 supportDBChecker
用于检查用户使用的数据库版本是否在系统的支持的范围之内,如果用户使用了不被支持的数据库版本,
则会抛出运行时异常 UnsupportedDBVersionException。测试方法 supportDBChecker 在数据库版
本不支持时是否会抛出指定异常的单元测试方法大体如下:

1
2
3
4
@Test(expected=UnsupportedDBVersionException.class)
public void unsupportedDBCheck(){
……
}

timeout

指定被测试方法被允许运行的最长时间应该是多少,如果
测试方法运行时间超过了指定的毫秒数,则 JUnit 认为测试失败。这个参数对于性能测试有一定的帮助。
例如,如果解析一份自定义的 XML 文档花费了多于 1 秒的时间,就需要重新考虑 XML 结构的设计,
那单元测试方法可以这样来写:

1
2
3
4
@Test(timeout=1000)
public void selfXMLReader(){
……
}

忽略测试方法

JUnit 提供注解 org.junit.Ignore 用于暂时忽略某个测试方法,因为有时候由于测试环境受限,并不能
保证每一个测试方法都能正确运行。例如下面的代码便表示由于没有了数据库链接,提示 JUnit 忽略测试方法 unsupportedDBCheck:

1
2
3
4
5
@Ignore(“db is down”)
@Test(expected=UnsupportedDBVersionException.class)
public void unsupportedDBCheck(){
……
}

但是一定要小心。注解 org.junit.Ignore 只能用于暂时的忽略测试,如果需要永远忽略这些测试,一定
要确认被测试代码不再需要这些测试方法,以免忽略必要的测试点。

测试套件

在实际开发中,单元测试类会越来越多,这个时候我们再一个一个的运行测试类就悲剧了.
所幸的是 Junit 为我们提供了一种批量运行测试类的方法,叫测试套件.
写法:

  1. 创建一个空类作为测试套件的入口
  2. 使用注解 org.junit.ranner.RunWith 和 org.junit.runners.Suite.SuiteClasses 修饰这个空类
  3. 将 org.junit.runners.Suite 作为参数传入注解 RunWith,以提示 Junit 为此类使用套件运行期执行
  4. 将需要放入此测试套件的测试类组成数组作为注解 SuiteClasses 的参数
  5. 保证这个空类使用 public 修饰,而且存在公开的不带任何参数的构造函数

JUnit 和 Ant

ant 提供了两个 target : junit 和 junitreport 运行所有测试用例,并生成 html 格式的报表
具体操作如下:
1.将 junit.jar 放在 ANT_HOMElib 目录下
2.修改 build.xml ,加入如下 内容:
————– One or more tests failed, check the report for detail… —————————–
运行 这个 target ,ant 会运行每个 TestCase,在 report 目录下就有了 很多 TEST*.xml 和 一些网页打开 report 目录下的 index.html 就可以看到很直观的测试运行报告,一目了然。
在 Eclipse 中开发、运行 JUnit 测试相当简单。因为 Eclipse 本身集成了 JUnit 相关组件,并对 JUnit 的运行提供了无缝的支持。

总结

下面是一些具体的编写测试代码的技巧或较好的实践方法:

  1. 不要用 TestCase 的构造函数初始化 Fixture,而要用 setUp() 和 tearDown() 方法。
  2. 不要依赖或假定测试运行的顺序,因为 JUnit 利用 Vector 保存测试方法。所以不同的平台会按不同的顺序从 Vector 中取出测试方法。
  3. 避免编写有副作用的 TestCase。例如:如果随后的测试依赖于某些特定的交易数据,就不要提交交易数据。简单的回滚就可以了。
  4. 当继承一个测试类时,记得调用父类的 setUp() 和 tearDown() 方法。
  5. 将测试代码和工作代码放在一起,一边同步编译和更新。(使用 Ant 中有支持 junit 的 task.)
  6. 测试类和测试方法应该有一致的命名方案。如在工作类名前加上 test 从而形成测试类名。
  7. 确保测试与时间无关,不要依赖使用过期的数据进行测试。导致在随后的维护过程中很难重现测试。
  8. 如果你编写的软件面向国际市场,编写测试时要考虑国际化的因素。不要仅用母语的 Locale 进行测试。
  9. 尽可能地利用 JUnit 提供地 assert/fail 方法以及异常处理的方法,可以使代码更为简洁。
  10. 测试要尽可能地小,执行速度快。
  11. 不要硬性规定数据文件的路径。
  12. 利用 Junit 的自动异常处理书写简洁的测试代码
    事实上在 Junit 中使用 try-catch 来捕获异常是没有必要的,Junit 会自动捕获异常。那些没有被捕获的异常就被当成错误处理。
  13. 充分利用 Junit 的 assert/fail 方法
    • assertSame() 用来测试两个引用是否指向同一个对象
    • assertEquals() 用来测试两个对象是否相等
  14. 确保测试代码与时间无关
  15. 使用文档生成器做测试文档。

junit3.x

  1. 使用 junit3.x 版本进行单元测试时,测试类必须要继承于 TestCase 父类;
  2. 测试方法需要遵循的原则:
    • public 的
    • void 的
    • 无方法参数
    • 方法名称必须以 test 开头
  3. 不同的 Test Case 之间一定要保持完全的独立性,不能有任何的关联。
  4. 我们要掌握好测试方法的顺序,不能依赖于测试方法自己的执行顺序。

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class TestMyNumber extends TestCase {
private MyNumber myNumber;
public TestMyNumber(String name) {
super(name);
}
// 在每个测试方法执行 [之前] 都会被调用
@Override
public void setUp() throws Exception {
// System.out.println("欢迎使用Junit进行单元测试…");
myNumber = new MyNumber();
}
// 在每个测试方法执行 [之后] 都会被调用
@Override
public void tearDown() throws Exception {
// System.out.println("Junit单元测试结束…");
}
public void testDivideByZero() {
Throwable te = null;
try {
myNumber.divide(6, 0);
Assert.fail("测试失败");
} catch (Exception e) {
e.printStackTrace();
te = e;
}
Assert.assertEquals(Exception.class, te.getClass());
Assert.assertEquals("除数不能为 0 ", te.getMessage());
}
}

junit4.x

  1. 使用 junit4.x 版本进行单元测试时,不用测试类继承 TestCase 父类,因为,junit4.x 全面引入了 Annotation 来执行我们编写的测试。[3]
  2. junit4.x 版本,引用了注解的方式,进行单元测试;
  3. junit4.x 版本我们常用的注解:
    • @Before 注解:与 junit3.x 中的 setUp() 方法功能一样,在每个测试方法之前执行;
    • @After 注解:与 junit3.x 中的 tearDown() 方法功能一样,在每个测试方法之后执行;
    • @BeforeClass 注解:在所有方法执行之前执行;
    • @AfterClass 注解:在所有方法执行之后执行;
    • @Test(timeout = xxx) 注解:设置当前测试方法在一定时间内运行完,否则返回错误;
    • @Test(expected = Exception.class) 注解:设置被测试的方法是否有异常抛出。抛出异常类型为:Exception.class;
    • @Ignore 注解:注释掉一个测试方法或一个类,被注释的方法或类,不会被执行。

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class TestMyNumber {
private MyNumber myNumber;
@BeforeClass
// 在所有方法执行之前执行
public static void globalInit() {
System.out.println("init all method...");
}
@AfterClass
// 在所有方法执行之后执行
public static void globalDestory() {
System.out.println("destory all method...");
}
@Before
// 在每个测试方法之前执行
public void setUp() {
System.out.println("start setUp method");
myNumber = new MyNumber();
}
@After
// 在每个测试方法之后执行
public void tearDown() {
System.out.println("end tearDown method");
}
@Test(timeout=600)// 设置限定测试方法的运行时间 如果超出则返回错误
public void testAdd() {
System.out.println("testAdd method");
int result = myNumber.add(2, 3);
assertEquals(5, result);
}
@Test
public void testSubtract() {
System.out.println("testSubtract method");
int result = myNumber.subtract(1, 2);
assertEquals(-1, result);
}
@Test
public void testMultiply() {
System.out.println("testMultiply method");
int result = myNumber.multiply(2, 3);
assertEquals(6, result);
}
@Test
public void testDivide() {
System.out.println("testDivide method");
int result = 0;
try {
result = myNumber.divide(6, 2);
} catch (Exception e) {
fail();
}
assertEquals(3, result);
}
@Test(expected = Exception.class)
public void testDivide2() throws Exception {
System.out.println("testDivide2 method");
myNumber.divide(6, 0);
fail("test Error");
}
public static void main(String[] args) {
}
}