JUnit_单元测试之道

About testing

单元测试

单元测试(unit testing)就是指对软件中的最小可测试单元进行检查和验证,根据语言和具体情况的不同,可以是,类、方法等。

单元测试可以由两种方式完成。

人工测试 自动测试
通过main方法(或其他)执行需要测试的单元,然后人为的观察输出确定是否正确称为人工测试。 借助工具支持并且利用自动工具执行用例被称为自动测试。
消耗时间并单调:由于测试用例是由人力资源执行,所以非常缓慢并乏味。 速度快,人力资源需求少
可信度较低:人工测试可信度较低是可能由于人工错误导致测试运行时不够精确。 可信度更高:自动化测试每次运行时精确地执行相同的操作。
非程式化:编写复杂并可以获取隐藏的信息的测试的 话,这样的程序无法编写。 程式化:试验员可以编写复杂的测试来显示隐藏信息。

Cases

一个基本的开发测试过程包括:编写function code,编写测试case code,编写运行测试code的程序。

在java中,通常需要测试的最小单元是function。

case1

如果我们需要测试下面的plus functoon

1
2
3
4
5
6
package cc.openhome;
public class Calculator {
public int plus(int op1, int op2) {
return op1 + op2;
}
}

我们可以编写下面的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorTest {
public static void testPlus() {
Calculator calculator = new Calculator();
int expected = 3;
int result = calculator.plus(1, 2);
if(expected == result) {
System.out.println("成功!");
}
else {
System.out.printf(
"失敗,預期為 %d,但是傳回 %d!%n", expected, result);
}
}
}

然后执行测试:

1
2
3
4
5
6
7
package test.cc.openhome;

public class TestRunner {
public static void main(String[] args) {
CalculatorTest.testPlus();
}
}

大功告成!然而这是开始。

case2

当然一个类中一般包含多个方法,需要测试的方法也会有多个。
假设我们增加了方法:

1
2
3
4
5
6
7
8
9
10
package cc.openhome;
public class Calculator {
public int plus(int op1, int op2) {
return op1 + op2;
}

public int minus(int op1, int op2) {
return op1 - op2;
}
}

最直接的,我们可以增加一个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorTest {
...

public static void testMinus() {
Calculator calculator = new Calculator();
int expected = 1;
int result = calculator.minus(3, 2);
if(expected == result) {
System.out.println("正確!");
}
else {
System.out.printf(
"錯誤,傳回 %d,但應該是 %d!%n", result, expected);
}
}
}

我们可以执行测试如下:

1
2
3
4
5
6
7
8
package test.cc.openhome;

public class TestRunner {
public static void main(String[] args) {
CalculatorTest.testPlus();
CalculatorTest.testMinus();
}
}

可以注意到,这两个测试方法中的判别结果正确与否的逻辑是一样的,我们可以重构将其分离出来:

1
2
3
4
5
6
7
8
9
10
11
12
package test.cc.openhome;
public class Assert {
public static void assertEquals(int expected, int result) {
if(expected == result) {
System.out.println("正確!");
}
else {
System.out.printf(
"失敗,預期為 %d,但是傳回 %d!%n", expected, result);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorTest {
public static void testPlus() {
Calculator calculator = new Calculator();
int expected = 3;
int result = calculator.plus(1, 2);
Assert.assertEquals(expected, result);
}
public static void testMinus() {
Calculator calculator = new Calculator();
int expected = 1;
int result = calculator.minus(3, 2);
Assert.assertEquals(expected, result);
}
}

然而,当前的处理方法是,每次function code增加一个方法,就需要对于的增加一个测试方法,同时还要对应的在测试执行程序(TestRunner)中增加调用测试方法。

case3

为了避免每次都要修改测试执行程序(TestRunner),我们可以将测试抽象为接口,然后将test case改为类(之前是方法级别),实现测试接口:

1
2
3
4
package test.cc.openhome;
public interface Test {
void run();
}

可以修改测试执行程序(TestRunner)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package test.cc.openhome;
import java.util.*;
public class TestRunner {
private List<Test> tests;
public TestRunner() {
tests = new ArrayList<Test>();
}
public void add(Test test) {
tests.add(test);
}
public void run() {
for(Test test : tests) {
test.run();
}
}
}

之后每次需要测试新增的方法,我们不需要再修改TestRunner的代码,只需要添加一个新的testcase的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorPlusTest implements Test {
@Override
public void run() {
Calculator calculator = new Calculator();
int expected = 3;
int result = calculator.plus(1, 2);
Assert.assertEquals(expected, result);
}
}

package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorMinusTest implements Test {
@Override
public void run() {
Calculator calculator = new Calculator();
int expected = 1;
int result = calculator.minus(3, 2);
Assert.assertEquals(expected, result);
}
}

执行测试:

1
2
3
4
5
6
7
8
9
package test.cc.openhome;
public class CalculatorTest {
public static void main(String[] args) {
TestRunner runner = new TestRunner();
runner.add(new CalculatorPlusTest());
runner.add(new CalculatorMinusTest());
runner.run();
}
}

case4

还没结束。。前面的测试只能对单个单个的方法进行测试,不能对test进行组合,设计TestSuite用于组合多个test为一个test。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package test.cc.openhome;
import java.util.*;
public class TestSuite implements Test {
private List<Test> tests;
public TestSuite() {
tests = new ArrayList<Test>();
}
@Override
public void run() {
for (Test test : tests) {
test.run();
}
}
public void add(Test test) {
tests.add(test);
}
}

这样就可以将多个test组合为一个test,然后按照一个test进行执行。

case5

还有问题。。目前每次需要测试一个方法,都需要创建一个类实现test接口。当方法很多时,这是一个很大的问题,费时费力。

最开始的设计时,test是方法级别,由于需要保持TestRunner不总是被修改,我们设计了test接口并将每个test改为了class级别。有没有方法可以保持TestRunner的稳定,并且使每个test回到方法级别?

答案当然是有的,我们可以将所有的test以方法级别都放在一个类中,但是每次具体选择哪个测试方法由参数决定。

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
package test.cc.openhome;
import java.lang.reflect.*;
public class TestCase extends Assert implements Test {
private String fName;

public TestCase() {}
public TestCase(String name) {
fName = name;
}

protected void setUp() {}
protected void tearDown() {}

@Override
public void run() {
setUp();
runTest();
tearDown();
}

public void runTest() {
Method runMethod= null;
try {
runMethod= getClass().getMethod(fName, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
if (!Modifier.isPublic(runMethod.getModifiers())) {
throw new RuntimeException("方法 \"" + fName + "\" 必須是 public");
}
try {
runMethod.invoke(this, new Class[0]);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public String getName() {
return fName;
}

public void setName(String name) {
fName = name;
}
}

有好一些吗?好像是有,至少不用分别建立Test的实作类别了,然而你还可以让测试人员更方便一些。你想在执行测试时,用每个testXXX()方法名称 来建构TestCase的子类别实例,而后运用反射(Reflection)来执行那些testXXX()方法, 让测试人员不用自己去作一些建立物件的动作。为此,你要重构TestSuite:

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
package test.cc.openhome;
import java.lang.reflect.*;
import java.util.*;
public class TestSuite implements Test {
private List<Test> tests = new ArrayList<Test>();

public TestSuite() {}
public TestSuite(Class clz) {
// 找出每個test開頭的方法
Method[] methods = clz.getDeclaredMethods();
for (Method method : methods) {
if (Modifier.isPublic(method.getModifiers())
&& method.getName().startsWith("test")) {
Constructor constructor = null;
try {
constructor = clz.getConstructor();
// 建立TestCase實例
TestCase testCase = (TestCase) constructor.newInstance();
// 設定要呼叫的testXXX()方法
testCase.setName(method.getName());
// 加入測試
add(testCase);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}

@Override
public void run() {
for (Test test : tests) {
test.run();
}
}

public void add(Test test) {
tests.add(test);
}
public void add(Class clz) {
tests.add(new TestSuite(clz));
}
}
1
2
3
4
5
6
7
8
9
package test.cc.openhome;
public class TestRunner {
public static void run(Test test) {
test.run();
}
public static void run(Class clz) {
run(new TestSuite(clz));
}
}

现在,如果需要写测试,只需要:

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
package test.cc.openhome;
import cc.openhome.Calculator;
public class CalculatorTest extends TestCase {
private Calculator calculator;

public CalculatorTest() {}
public CalculatorTest(String name) {
super(name);
}

@Override
protected void setUp() {
calculator = new Calculator();
}
@Override
protected void tearDown() {
calculator = null;
}

public void testPlus() {
int expected = 5;
int result = calculator.plus(3, 2);
assertEquals(expected, result);
}

public void testMinus() {
int expected = 1;
int result = calculator.minus(3, 2);
assertEquals(expected, result);
}

public static void main(String[] args) {
TestRunner.run(CalculatorTest.class);
}
}

我们还可以自由组合各种形式的测试,可以添加一个test方法,也可执行所有test方法,也可混合组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
package test.cc.openhome;
public class CalculatorAll {
public static Test suite() {
TestSuite suite = new TestSuite();
suite.add(CalculatorPlusMinusTest.suite());
suite.add(CalculatorTest.class);
suite.add(new CalculatorTest("testPlus"));
return suite;
}
public static void main(String[] args) {
TestRunner.run(suite());
}
}

case6

在很多情况下,我们希望收集测试的结果,而只是简单的输出到控制台,而是希望进一步处理和操作,这时我们需要设计TestResult用以添加测试结果信息,这里就不在细述。

测试工具

由上面的case可以看出,设计一个强大的可扩展的test系统有很多问题需要考虑,测试工具需要做到:

易于撰写测试
只要撰写testXXX()方法,程式会自动找出并进行测试,事实上,经由设计,在JDK 5以上,还可以使用标注(Annotation)来标示测试方法,JUnit 4.x就可以如此设定。

易于组合测试
可指定测试单一testXXX()方法,可 自动发掘测试案例(Test case)中的所有testXXX()方法,可以自由组合TestSuite等。

让单元测试彼此独立
每个testXXX()方法是封装在一个TestCase的实例中运行,所以单元测试与单元测试间是彼此独立的,如果单元测试有前置状态,你也可以利用setUp()、tearDown()来使之处于前置状态。

自动收集并产生结果
经由适当的组合,你可以一次运行所指定的任意个单元测试,过程中会自动收集结果,最后可用指定的方式(Test runner)来呈现结果,事实上,藉由Ant或Maven之类的工作,你还可以进一步产生各种类型的测试报告并邮寄至相关人等。

JUnit

JUnit是个单元测试(Unit test)框架,单元测试指的是测试一个工作单元(a unit of work)的行为。举例来说,对于建筑桥墩而言,一个螺丝钉、一根钢筋、一条钢索甚至一公斤的水泥等,都可谓是一个工作单元,验证这些工作单元行为或功能(硬度、张力等)是否符合预期,方可确保最后桥墩安全无虞。

测试一个单元,基本上要与其它的单元独立,否则你会在同时测试两个单元的正确 性,或是两个单元之间的合作行为。就软体测试而言,或支援物件导向的程式而言,例如Java,「通常」单元测试指的是测试某个方法,你给予该方法某些输入,预期该方法会产生某种输出,例如传回预期的值、产生预期的档案、新增预期的资料等。

JUnit 促进了“先测试后编码”的理念,强调建立测试数据的一段代码,可以先测试,然后再应用。这个方法就好比“测试一点,编码一点,测试一点,编码一点……”,增加了程序员的产量和程序的稳定性,可以减少程序员的压力和花费在排错上的时间。

  • JUnit 是一个开放的资源框架,用于编写和运行测试。
  • 提供注释来识别测试方法。
  • 提供断言来测试预期结果。
  • 提供测试运行来运行测试。
  • JUnit 测试允许你编写代码更快,并能提高质量。
  • JUnit 优雅简洁。没那么复杂,花费时间较少。
  • JUnit 测试可以自动运行并且检查自身结果并提供即时反馈。所以也没有必要人工梳理测试结果的报告。
  • JUnit 测试可以被组织为测试套件,包含测试用例,甚至其他的测试套件。
  • JUnit 在一个条中显示进度。如果运行良好则是绿色;如果运行失败,则变成红色。

JUnit 3 vs JUnit 4

Junit也处于不断发展的过程中,虽然基本思想没有大的变化,但是具体实现和使用,从JUnit 3到JUnit 4还是有一些变化。

上面描述的各个case的发展的最后面,就是JUnit 3的主要框架和思想了,当然JUnit 3提供了更完善和强大的设计和支持。

JUnit 3与JUnit 4主要区别,参考JUnit测试框架之JUnit3和JUnit4使用区别的总结

  • 在JUnit3中需要继承TestCase类,但在JUnit4中已经不需要继承TestCase
  • 在JUnit3中需要覆盖TestCase中的setUp和tearDown方法,其中setUp方法会在测试执行前被调用以完成初始化工作,而tearDown方法则在结束测试结果时被调用,用于释放测试使用中的资源,而在JUnit4中,只需要在方法前加上@Before,就代表这个方法用于初始化操作,如上面的beforeDoTest方法,方法名是随意的
  • 在JUnit3中对某个方法进行测试时,测试方法的命令是固定的,例如对addBook这个方法进行测试,需要编写名字为tetAddBook的测试方法,而在JUnit4中没有方法命令的约束,例如对addBook这个方法进行测试,那么可以编写addBookToLibrary的方法,然后在这个方法的前面加上@Test,这就代表这个方法是测试用例中的测试方法
  • 编写JUnit4的测试用例和编写一个普通的类没有什么区别,只是需要加上Annotation指定要测试的方法,这种松偶合的设计理念相当优秀,能很好把测试分离出来.使用JUnit4的Annotation功能,需要JDK 1.5或以上版本
  • JUnit 4还提供了其他基于注解的强大的功能的支持。

后面主要介绍JUnit 4的用法,当然很多也是从JUnit 3继承下来的。

参考http://openhome.cc/Gossip/JUnit/