单元测试

什么是单元测试?

单元测试(Unit Testing)由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试(Integration Testing)放到一块来对比。单元测试相对于集成测试来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行,是代码层级的测试。

这么说比较理论,我举个例子来解释一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Text 
{
private String content;

public Text(String content)
{
this.content = content;
}

/**
* 将字符串转化成数字,忽略字符串中的首尾空格
* 如果字符串中包含除首尾空格之外的非数字字符,则返回 null
*/
public Integer toNumber()
{
if (content == null || content.isEmpty())
{
return null;
}
//...省略代码实现...
return null;
}
}

实际上,写单元测试本身不需要什么高深技术。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。

为了保证测试的全面性,针对 toNumber() 函数,我们需要设计下面这样几个测试用例:

  • 如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123;
  • 如果字符串是空或者 null,toNumber() 函数返回:null;
  • 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123;
  • 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123;
  • 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null;

当我们设计好测试用例之后,剩下的就是将其翻译成代码了(我们这里没有使用任何测试框架):

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
public class Assert 
{
public static void assertEquals(Integer expectedValue, Integer actualValue)
{
if (actualValue != expectedValue)
{
String message = String.format(
"Test failed, expected: %d, actual: %d.", expectedValue, actualValue);
System.out.println(message);
}
else
{
System.out.println("Test succeeded.");
}
}

public static boolean assertNull(Integer actualValue)
{
boolean isNull = actualValue == null;
if (isNull)
{
System.out.println("Test succeeded.");
}
else
{
System.out.println("Test failed, the value is not null:" + actualValue);
}
return isNull;
}
}

public class TestCaseRunner
{
public static void main(String[] args)
{
System.out.println("Run testToNumber()");
new TextTest().testToNumber();

System.out.println("Run testToNumber_nullOrEmpty()");
new TextTest().testToNumber_nullOrEmpty();

System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsLeadingAndTrailingSpaces();

System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();

System.out.println("Run testToNumber_containsInvalidCharacters()");
new TextTest().testToNumber_containsInvalidCharacters();
}
}

public class TextTest
{
public void testToNumber()
{
Text text = new Text("123");
Assert.assertEquals(123, text.toNumber());
}

public void testToNumber_nullOrEmpty()
{
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());

Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}

public void testToNumber_containsLeadingAndTrailingSpaces()
{
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}

public void testToNumber_containsMultiLeadingAndTrailingSpaces()
{
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}

public void testToNumber_containsInvalidCharacters()
{
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());

Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}

为什么要写单元测试?

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review),我总结了以下几点单元测试的好处:

  1. 单元测试能有效地帮你发现代码中的 bug
    能否写出 bug free 的代码,是判断工程师编码能力的重要标准之一,也是很多大厂面试考察的重点,特别是像 FLAG 这样的外企。即便像我这样代码写了十几年,逻辑还算缜密、清晰的人,通过单元测试也常常会发现代码中的很多考虑不全面的地方。它节省了我很多 fix 低级 bug 的时间,能够有时间去做其他更有意义的事情,我也因此在工作上赢得了很多人的认可。可以这么说,坚持写单元测试是保证我的代码质量的一个“杀手锏”,也是帮助我拉开与其他人差距的一个“小秘密”
  2. 写单元测试能帮你发现代码设计上的问题
    对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。
  3. 单元测试是对集成测试的有力补充
    程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用 mock 的方式,控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。除此之外,对于一些复杂系统来说,集成测试也无法覆盖得很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备。
  4. 写单元测试的过程本身就是代码重构的过程
    写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。
  5. 阅读单元测试能帮助你快速熟悉代码
    阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。在没有文档和注释的情况下,单元测试就起了替代性作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
  6. 单元测试是 TDD 可落地执行的改进方案
    测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。不过,要让程序员能彻底地接受和习惯这种开发模式还是挺难的,毕竟很多程序员连单元测试都懒得写,更何况在编写代码之前先写好测试用例了。单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点。

如何编写单元测试?

写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。

在把测试用例翻译成代码的时候,我们可以利用单元测试框架,来简化测试代码的编写。比如,Java 中比较出名的单元测试框架有 JUnit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数)等。借助它们,我们在编写测试代码的时候,只需要关注测试用例本身的编写即可

针对 toNumber() 函数的测试用例,我们利用 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
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
import org.junit.Assert;
import org.junit.Test;

public class TextTest
{
@Test
public void testToNumber()
{
Text text = new Text("123");
Assert.assertEquals(new Integer(123), text.toNumber());
}

@Test
public void testToNumber_nullOrEmpty()
{
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());

Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}

@Test
public void testToNumber_containsLeadingAndTrailingSpaces()
{
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}

@Test
public void testToNumber_containsMultiLeadingAndTrailingSpaces()
{
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}

@Test
public void testToNumber_containsInvalidCharacters()
{
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());

Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}

对于如何使用这些单元测试框架,大部分框架都给出了非常详细的官方文档,你可以自行查阅。关于如何编写单元测试,我更希望传达给你一些我的经验总结。具体包括以下几点:

  1. 写单元测试真的是件很耗时的事情吗?
    尽管单元测试的代码量可能是被测代码本身的 1~2 倍,写的过程很繁琐,但并不是很耗时。毕竟我们不需要考虑太多代码设计上的问题,测试代码实现起来也比较简单。不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行。

  2. 对单元测试的代码质量有什么要求吗?
    单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,我们对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。

  3. 单元测试只要覆盖率高就够了吗?
    单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计,比如,JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖。
    不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case。像下面这段代码,我们只需要一个测试用例就可以做到 100% 覆盖率,比如 cal(10.0, 2.0),但并不代表测试足够全面了,我们还需要考虑,当除数等于0的情况下,代码执行是否符合预期:

    1
    2
    3
    4
    5
    6
    7
    public double cal(double a, double b) 
    {
    if (b != 0)
    {
    return a / b;
    }
    }

    实际上,过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率,写很多没有必要的测试代码,比如 get、set 方法非常简单,没有必要测试。从过往的经验上来讲,一个项目的单元测试覆盖率在 60~70% 即可上线。

  4. 写单元测试需要了解代码的实现逻辑吗?
    单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷

  5. 如何选择单元测试框架?
    写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。这个时候,我们要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架

单元测试为何难落地执行?

写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现破窗效应,慢慢的,大家就都不写了,这种情况很常见。

还有一种情况就是,由于历史遗留问题,原来的代码都没有写单元测试,代码已经堆砌了十几万行了,不可能再一个一个去补单元测试。这种情况下,我们首先要保证新写的代码都要有单元测试,其次,每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们有足够强的主人翁意识(ownership),毕竟光靠 leader 督促,很多事情是很难执行到位的。

除此之外,还有人觉得,有了测试团队,写单元测试就是浪费时间,没有必要。程序员这一行业本该是智力密集型的,但现在很多公司把它搞成劳动密集型的,包括一些大厂,在开发过程中,既没有单元测试,也没有 Code Review 流程。即便有,做的也是差强人意。写好代码直接提交,然后丢给黑盒测试狠命去测,测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复。在这样的开发模式下,团队往往觉得没有必要写单元测试,但如果我们把单元测试写好、做好 Code Review,重视起代码质量,其实可以很大程度上减少黑盒测试的投入