本文旨在配合图示帮助读者理解单元测试中的“Mock”这个技巧概念,希望下次开发时大家可以用到这个技巧。
简单说一下单元测试的概念:
- 测试对象通常是(单个模块的)单个方法。
- 目标是用代码把对方法的行为的期望固定成能重复执行的脚本,以实现自动化测试。
- 通过预先定义输入值与对应的断言(assertion)
这种二元组,来检查某个状态值是否符合预期。(语义即:我期望给方法传值A时,返回值|输入值变成|实例的某个依赖收到值B)
- 断言是另一个二元组,即期望值和实际值,常用
except
和actual
两个变量名。
调用方法会产生两种行为,两种行为可同时发生,但至少发生一种,要不它就失去了作为方法的方生意义:
-
方法根据输入值“只读”地返回对应输出值,非常直白,没有改变包括输入值在内的其他数据。通常把这种方法称为“无副作用(side-effect)”的方法。
-
方法根据输入值,改变了输入值本身,或者方法所属的模块实例(this,self),或者模块实例所包含(has-a)的依赖的状态(,并返回结果)。通常称为“有副作用”的方法。
我们的测试要覆盖这两种行为,尤其要注意不太直观的第二种行为。(但是说测第二种行为,也会忽略方法对模块实例本身状态的改动,毕竟这个算模块自己的私事,我们强行去测就破坏封装性了(比如在 Java 里用反射去看类私有字段的值,也不是不能做到)。)
我们可以直接用断言测试这两种值。很普通,大家都会,没什么好讲的,但是还是要写出来。
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 Test {
public int add(int a, int b) {
return a + b;
}
// 测返回值
@Test
public void testAdd() {
Test t = new Test();
assertEquals(3, t.add(1, 2));
}
public void changeName(User user) {
user.setName("Alice");
}
// 测输入值
@Test
public void testChangeName() {
Test t = new Test();
assertEquals("Alice", t.changeName(User.builder.name("Bob").build()));
}
}
|
返回值和输入值,我们能直接拿到,关键是怎么“看”到模块的依赖的改变,这就是 Mock 机制的意义所在。
关于 Mock 概念本身什么意思,这个 Stackoverflow 的问题
被回答得很好,建议一读。简单总结,就是给被测模块注入一个模拟的依赖,一个25仔,让它给我们通风报信被测模块对它做了什么。谁让方法自己不给我们说的。
这里使用 Java 语言中的Mockito 库 演示 Mock 机制,这个技巧不容易徒手实现,需要借用现成的库。
一个坏坏的计算模块要在 add 方法中加入删库的逻辑,但它的返回值表现得就像普通的 add 方法一样。可惜根据单一职责原则,它不能独立执行删库这个操作,要调用另一个删库组件 DbDeleter 的 delete()
方法。我们会配合使用 Mock 技巧来编写对删库操作的期望。
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 EvilCalculator {
// 删库组件依赖。
private DbDeleter deleter;
// 这是 Java 的类构造方法
public EvilCalculator(DbDeleter deleter) {
this.deleter = deleter;
}
public int add(int a, int b) {
// 坏!
if ("删库成功!".equals(this.deleter.delete("生产环境数据库"))) {
// 开瓶香槟庆祝
}
// 但仍然伪装自己是正常的 add 方法。
return a + b;
}
}
/**
* 借助 Junit 框架让 Mockito 库可以自动扫描这个测试类中的注解,
* 减轻编码工作量
*/
@RunWith(MockitoJUnitRunner.class)
public class getEntitiesByCategoryTests extends MethodTestsClass {
// 被测模块所需的依赖
// 一般都给 Mock 的实例名前面加上 Mock 前缀。
// @Mock 注解生成一个对应类型的 Mock 实例。
@Mock
DbDeleter mockDeleter;
// 被测模块
// @InjectMocks 注解把生成的 Mock 删库实例(通过调用构造方法自动地)注入到坏计算模块中。
// 即,让25仔打入坏蛋内部。
@InjectMocks
EvilCalculator calculator;
@Test
public void withNormalResponse() {
// 设置 Mock 删库实例被调用 doIt()方法时的返回值,让坏计算模块以为删库成功了。
// Mockito 的话,不设这个调用时会返回 null。
given(mockDeleter.doIt())
.willReturn("删库成功!");
// 调用坏计算模块,同时也把返回值测了。
assertThat(calculator.add(1, 2), is(3));
// 让我们看看坏计算模块对 Mock 删库模块说了什么
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
// 捕获(查看)坏计算模块传给 Mock 删库模块的 delete()方法的参数
// 顺便期望坏计算模块只调用了一次删库模块的 delete()方法
verify(mockDeleter, times(1)).delete(mapperArgCaptor.capture());
// 早就料到(期望)
assertThat(mapperArgCaptor.getValue(), is("生产环境数据库"));
}
}
|
特别的,Mockito 含有断言 Mock 组件行为的 API,比如上面示例中断言方法调用次数。一般单独的 Mock 技巧只能用来取值,还要结合其他断言库去验证值的正确性。Mock 不仅可以让我们取得被测模块传给依赖的值,还能让我们设定依赖对被测模块调用的返回值,我称之为“第二种输入”,比如让依赖抛异常,看被测模块能否妥善处理。
Mock 技巧不止局限于单元测试层面,这是一个通用概念。假如上面的坏计算模块不再依赖删库模块,而是自己实现了删库操作……没事,我们还能往数据库里导假数据 或者一整个的数据库实例 都是假的,这是在集成测试中应用 Mock 概念的一个例子。