白话解释单元测试中的 Mock 概念

使用 Mock 技巧测试模块对依赖的调用

#前言

本文旨在配合图示帮助读者理解单元测试中的“Mock”这个技巧概念,希望下次开发时大家可以用到这个技巧。

简单说一下单元测试的概念:

  • 测试对象通常是(单个模块的)单个方法。
  • 目标是用代码把对方法的行为的期望固定成能重复执行的脚本,以实现自动化测试。
  • 通过预先定义输入值与对应的断言(assertion) 这种二元组,来检查某个状态值是否符合预期。(语义即:我期望给方法传值A时,返回值|输入值变成|实例的某个依赖收到值B)
  • 断言是另一个二元组,即期望值实际值,常用exceptactual两个变量名。

调用方法会产生两种行为,两种行为可同时发生,但至少发生一种,要不它就失去了作为方法的方生意义:

  1. 方法根据输入值“只读”地返回对应输出值,非常直白,没有改变包括输入值在内的其他数据。通常把这种方法称为“无副作用(side-effect)”的方法。

  2. 方法根据输入值,改变了输入值本身,或者方法所属的模块实例(this,self),或者模块实例所包含(has-a)的依赖的状态(,并返回结果)。通常称为“有副作用”的方法。

我们的测试要覆盖这两种行为,尤其要注意不太直观的第二种行为。(但是说测第二种行为,也会忽略方法对模块实例本身状态的改动,毕竟这个算模块自己的私事,我们强行去测就破坏封装性了(比如在 Java 里用反射去看类私有字段的值,也不是不能做到)。)

一张展示一个看起来人畜无害的 add 方法可以背地里偷偷删库的图片

#不用 Mock 技巧,直接测试方法的返回值或输入值的改变

我们可以直接用断言测试这两种值。很普通,大家都会,没什么好讲的,但是还是要写出来。

 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 机制的意义所在。

关于 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("生产环境数据库"));
    }
}

一张展示用 Mock 技巧回避删库的图片

#延伸

特别的,Mockito 含有断言 Mock 组件行为的 API,比如上面示例中断言方法调用次数。一般单独的 Mock 技巧只能用来取值,还要结合其他断言库去验证值的正确性。Mock 不仅可以让我们取得被测模块传给依赖的值,还能让我们设定依赖对被测模块调用的返回值,我称之为“第二种输入”,比如让依赖抛异常,看被测模块能否妥善处理。

Mock 技巧不止局限于单元测试层面,这是一个通用概念。假如上面的坏计算模块不再依赖删库模块,而是自己实现了删库操作……没事,我们还能往数据库里导假数据 或者一整个的数据库实例 都是假的,这是在集成测试中应用 Mock 概念的一个例子。

展示单元测试需要设置输入值和依赖的返回值,而要断言输入值、输出值和被测对依赖的调用值的图片

updatedupdated2023-08-082023-08-08