放牛的

放牛的日子,是人生初恋的诗...

0%

单元测试之我见

工作中,我习惯性的写单元测试。

我并没有受过专业的单元测试训练,也没有别人的指导和要求,是自己这几年工作下来,在没有外在要求的环境中,自己养成的习惯。

我知道这个习惯给我的工作带来了不小的帮助,这次把它写出来,是自己的一次总结,写给自己。

如果你读到了,也可以作为参考。

1. 什么是单元测试?

先看「维基百科」怎么说:

计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

通常来说,程式设计师每修改一次程式就会进行最少一次单元测试,在编写程式的过程中前后很可能要进行多次单元测试,以证实程式达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是什么必须的,但也不坏,这牵涉到专案管理的政策决定。

每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用stubs、mock[1]或fake等测试马甲程序。单元测试通常由软件开发人员编写,用于确保他们所写的代码符合软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。

下面是我个人理解。

1.1 是开发过程的一部分

「单元测试」虽名为「测试」,却是开发人员干的活,在开发阶段测试自己「即将写的代码」或「刚写好的代码」是否完成自己预期的功能。

在写代码的过程中,要知道自己在写的功能是干什么的,怎么知道这部分功能是对的,整个思考实现的过程,脑中一直都有个抽象的模型。当完成模型中的一小块功能后,马上验证下对与不对,再继续后面的工作。

1.2 是为开发服务的

我不太赞成强制为所有代码写单元测试,并过度强调测试覆盖,这样的结果会让我全身不自在,像被绑起来一样。写代码是要有一定的自由度的。

要求高的时候,单元测试是产品的一部分,要为产品的质量保驾护航。大部分情况下,单元测试是为我服务,而不是我去满足单元测试的各种要求。

换句话说,是因为它能给我带来帮助,对我有益,我才愿意去写它。

1.3 是一个验证的工作

我知道自己写的代码,大多数时候,不是一次就能写对的。

与其让别人告诉我代码有问题,不如我自己先验证一下。

1.4 是针对单一功能

单元测试是针对单一功能,如果功能很多,出现了问题将很难准确定位。

写代码应该是件简单的事,将多个简单的功能都写对了,再将之组合起来,出现问题的概率会大大降低。

单一功能更方便测试,功能多了,要测试的点也多了,单元测试往往没办法写下去。

2. 为什么要写单元测试?

如果没有外在的要求,可能很多人不愿意写单元测试。我为什么要写单元测试?

2.1 直接写出来的代码,往往是不对的

或许有人能将代码一次写对,这是我羡慕的。

我也想一次把代码写对,但我的经验告诉我,这对我太难,难到几乎做不到。

于是我对自己降低了要求,不要求自己第一次写出来的代码是对的,要求自己每次提交的代码是对的。

在提交代码之前,多测试一下,这样做到的概率大大提高了。

2.2 更低的调试成本

自己要测试,用的Java又是静态语言,Hotswap并不是很有效,经常需要重启JVM。

如果做的项目小,启动时间在10秒以内,那也罢了。

大多数时候,不是启Tomcat,就是启Weblogic,启动时间都是分钟级别的,这样测试验证的时间成本太高了,改代码10秒钟,测试一下3分钟,结果是:更少的测试。

用单元测试就好多了,就算用Spring加载一大堆东西,启动时间也在10秒内。

2.3 代码修改后,仍需要验证

有句话叫:代码不是写出来的,是改出来的。

很少有代码是写出来不用修改的。或是因为需求变了,或是因为前期没有考虑周全,或是因为实际情况与预期的不完全一致。

不管什么原因导致的修改,改了总需要再验证一下,看看改的是不是对的。

所以,单元测试不只是第一次写的时候有用。如果换个人过来改,有没有 单元测试,区别不是一般的大。

2.4 验证部分功能

如果不写单元测试,想测试部分功能,往往是做不到的。组合在一起是一个功能,内部有很多小功能组成,而出问题是其中的一个,要测试这一个的需要满足N个条件,这种情况下,测试变成了个麻烦事。

有了单元测试,就方便多了。写代码的人,更愿意用代码解决问题,而不是花很多时间去搭建测试环境、创造测试条件。

3. 如何知道该写一个单元测试了?

代码分2种:一种要单元测试的,另一种是不需要写单元测试的。

如何区分这2种呢?

3.1 一个独立的功能

业务上是独立的功能,一般是要写一个测试Case的。

功能的划分,是设计阶段 要完成的。

独立的功能是单元测试的必要条件。

如果一个人说,他的代码没办法写单元测试,我会将之等价于:没有设计,没有模块化,自己都不知道在写什么,代码一团糟。。。

这种情况,不改代码真的没办法写单元测试。

3.2 容易出错

一个地方是否容易出错,写代码的人是能感觉到的。

比如:我对Mybatis写的查询,全部要做单元测试。因为它的SQL是写到XML文件中,没有编译器的保证,肉眼识别没那么靠谱,测一下就放心多了。

3.3 是接口

接口几乎总是满足测试条件的,也几乎总是要测试的。

3.4 有算法

用Java写的代码,需要设计算法的时候并不多,一旦有,就需要认真对待,算法要考虑周全嘛!

所以,写算法的代码时,就要准备好各个测试点。

4. 怎么写一个单元测试?

写单元测试要做哪些事?

4.1 测试的功能本身独立

独立的功能是一个基本要求,功能或大或小,要独立起来算一个功能,有输入输出,更直白的说,有一个函数,最好是纯函数。

如果没有,先安全的处理下代码。

参考书籍:《重构,改善既有的代码设计》。

4.2 工具生成测试代码框架

这是开发工具都会带的功能,根据源代码,生成测试这些代码的框架性的代码。

比如:Intellij IDEA的「JUnit」插件。

4.3 基本功能的测试

常规的输入输出,必做的。

4.4 异常Case测试

我是会分情况来做异常Case测试。经常将之省去。

4.5 性能测试

用单元测试跑跑,测一下性能也无不可。代码灵活嘛!

用单元测试的功能,启若干个线程,压一压性能,有时还是比专业工具更得心应手。

5. 方法与技巧

如何知道自己测试的结果是对的?

5.1 Print

将输出打印出来,自己判断一下。

没办法自动化,要靠人来判断。

用起来最简单。

还可以再偷个懒,debug启动,加个断点,连输出都不需要了。

5.2 Assert

单元测试自动化的基石。

image-20200226233216316

5.3 Mock

复杂的单元测试,能避免最好,如果一定需要,那就用吧!

比如下面的情况:

006tNbRwly1fwp1amtetkj30jj05tmza

当类A中有属性B 和 C,B中又有D和E时,为了测试A,需要先创建4个对象b,c,d,e,而我们却不测试它,这不合道理。

我们可以做2个假的对象mockB和 mockC,用假的对象返回期望值,来测试类A。

006tNbRwly1fwp1akyjotj30ji06z40f

6. 例子

最后,放几个我曾经写的单元测试,作为例子。

6.1 简单的Print

1
2
3
4
5
6
@Test
public void testAllXkqy() throws Exception {
Set<String> strings = repository.allXkqy("20180607A");
System.out.println(strings.size());
System.out.println(strings);
}

6.2 一个Assert的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void testHourDateTime() throws Exception {
assertEquals(of(2016, 1, 1, 7, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 0, 0, 0)));
assertEquals(of(2016, 1, 1, 7, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 0, 0)));
assertEquals(of(2016, 1, 1, 7, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 0)));

assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 0, 0, 1)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 0, 59, 100)));

assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 2, 0)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 12, 0)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 32, 0)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 52, 0)));

assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 8, 59, 59)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 9, 0, 0, 0)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 9, 0, 0)));
assertEquals(of(2016, 1, 1, 8, 0, 0, 0), hourDateTime(of(2016, 1, 1, 9, 0)));
assertEquals(of(2016, 1, 1, 9, 0, 0, 0), hourDateTime(of(2016, 1, 1, 9, 0, 0, 1)));
}

6.3 复杂一点的

使用Mock,自定义返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Before
public void before() throws Exception {
metricService = mock(MetricService.class);
hourSampleCache = new AccumulateHourSampleCache(metricService);
}

@Test
public void testSample() throws Exception {
when(metricService.sample("r.abc", of(2016, 1, 1, 9, 0, 0))).thenReturn(null);
assertNull(hourSampleCache.sample("r.abc", of(2016, 1, 1, 9, 0, 0)));

DataPoint dp2 = new DataPoint("r.abc", of(2016, 1, 1, 8, 0), "3.14", 192);
when(metricService.sample("r.abc", of(2016, 1, 1, 9, 0, 0))).thenReturn(dp2);
assertNull(hourSampleCache.sample("r.abc", of(2016, 1, 1, 9, 0, 0)));

DataPoint dp = new DataPoint("r.abc", of(2016, 1, 1, 9, 0), "3.14", 192);
when(metricService.sample("r.abc", of(2016, 1, 1, 9, 0))).thenReturn(dp);
assertEquals(dp, hourSampleCache.sample("r.abc", of(2016, 1, 1, 9, 0, 0)));

when(metricService.sample("r.abc", of(2016, 1, 1, 9, 0, 0))).thenReturn(null);
assertEquals(dp, hourSampleCache.sample("r.abc", of(2016, 1, 1, 9, 0, 0)));
}

6.4 更复杂一点的

下面是一个更复杂的例子,在使用Mock的基础上,增加了内部调用方法次数的验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Before
public void before() throws Exception {
metricService = Mockito.mock(MetricService.class);
repairRecorder = Mockito.mock(RepairRecorder.class);
hourlyMetricWriter = new HourlyMetricWriter(metricService, repairRecorder);
}

@Test
public void testWrite() throws Exception {
Mockito.when(metricService.read(metricHourly, ldt, ldt.withHour(23), false)).thenReturn(
Collections.emptyList());
hourlyMetricWriter.write(dph);
Mockito.verify(metricService, Mockito.times(1)).read(metricHourly, ldt, ldt.withHour(23), false);
Mockito.verify(metricService, Mockito.times(2)).write(Matchers.any(DataPoint.class));
Mockito.verify(metricService, Mockito.times(1)).write(dph);
Mockito.verify(metricService, Mockito.times(1)).write(new DataPoint(metricDaily, ldt, "3.14", 192));
}

6.5 测试性能

自己写几行代码,简单的测测性能。

这是不是单元测试,似乎并不重要。

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
@Test
public void write() throws Exception {
final int minHandle = 1179;
final int maxQueueSize = 1000;

final int threadCount = 5;
final int tagsPerThread = 1000;
final int records = 10000;
final int sendCycle = 60;

final int maxHandle = minHandle + tagsPerThread * threadCount * 2;
// int maxHandle = 3000;

final TagManagerImpl tagManager = new TagManagerImpl("192.168.1.98", 6767, "lpp", "12345");

ExecutorService threadPool = Executors.newFixedThreadPool(threadCount + 1);

final DateTime dateTime = new DateTime(2014, 2, 1, 0, 0, 0, 0);
final int logCycle = 60 * 60;

final BlockingQueue<Map<Integer, TagValue>> queue = new LinkedBlockingQueue<>();

threadPool.execute(new Runnable() {
@Override
public void run() {
for (int i = 0; i < records * sendCycle; i += sendCycle) {
DateTime dt = dateTime.plusSeconds(i);
Map<Integer, TagValue> map = new HashMap<>(tagsPerThread);
for (int j = minHandle; j < maxHandle; j += 2) {
map.put(j, new TagValue(dt, TagValue.DOUBLE, i + j, (short) 192));
if (map.size() == tagsPerThread) {
queue.offer(map);
map = new HashMap<>(tagsPerThread);
}
while (queue.size() > maxQueueSize) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (i % logCycle == 0) {
logger.info(String.format("Minute:%d", i / logCycle));
}
}
}
});

TimeUnit.SECONDS.sleep(1);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
threadPool.execute(new Runnable() {
private int count = 0;

@Override
public void run() {
try {
while (!queue.isEmpty()) {
Map<Integer, TagValue> take = queue.take();
tagManager.writeVal(new ArrayList<>(take.keySet()), new ArrayList<>(take.values()));
count++;

if (count % 100 == 0) {
logger.info(String.format("has been write count:%d, queue size:%d", count, queue.size()));
}
}
logger.info(String.format("This thread write total:%d", count));
} catch (Throwable e) {
e.printStackTrace();
}
}
});
}
threadPool.shutdown();
try {
threadPool.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
}

long endTime = System.currentTimeMillis();

logger.info(String.format("客户端数:%d", threadCount));
logger.info(String.format("每个客户端位号数:%d", tagsPerThread));
logger.info(String.format("每位号记录数:%d", records));
logger.info(String.format("数据频率:%d", sendCycle));
logger.info(String.format("用时:%d", (endTime - startTime) / 1000));
}