使用Mockito单元测试匹配参数时的顺序问题

最近在编写单元测试时发现了Mockito打桩时的顺序会影响Mock对象的实际反映,于是研究了一下其中的原理。在此记录其中的研究结果

# 背景

最近在编写单元测试时发现了Mockito打桩时的顺序会影响Mock对象的实际反映,于是研究了一下其中的原理。在此记录其中的研究结果

# 环境

本文基于spring boot 2.7.*和junit5。理论上所有基于spring boot 2.*和junit5的代码都使用

基于

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
</dependencies>

定义两个类用于Mocktio测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
@Service
public class ExampleChildService {

    public String parseIntToString(Integer id) {
        return String.valueOf(id);
    }
}

@Slf4j
@Service
public class ExampleFatherService {

    @Autowired
    private ExampleChildService exampleChildService;

    public String getDataById(Integer id) {
        return exampleChildService.parseIntToString(id);
    }
}

定义一个单元测试类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Slf4j
@SpringBootTest(classes = {ExampleFatherService.class, ExampleChildService.class})
public class MockitoTest {

    @Autowired
    private ExampleFatherService exampleFatherService;

    @MockBean
    private ExampleChildService exampleChildService;
}

# Mockito匹配参数返回不同值

一个经常被问到底问题就是:Mocktio中如何匹配不同值返回不同结果。这里是一个例子方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    @Test
    public void testMockitoSpecialValueReturn() {
        Mockito.when(exampleChildService.parseIntToString(Mockito.eq(5))).thenReturn("5");
        Mockito.when(exampleChildService.parseIntToString(Mockito.eq(10))).thenReturn("10");
        String result1 = exampleFatherService.getDataById(5);
        log.info("result1:{}", result1);
        String result2 = exampleFatherService.getDataById(10);
        log.info("result2:{}", result2);
        String result3 = exampleFatherService.getDataById(100);
        log.info("result3:{}", result3);
    }

解释:

  1. 第一个mockito.when指示mocktio进行打桩,在输入参数等于5时,返回值为5。所以result1等于5
  2. 第一个mockito.when指示mocktio进行打桩,在输入参数等于10时,返回值为10。所以result2等于10
  3. 第三个result3在mocktio中无法匹配,所以默认返回null

输出结果

1
2
3
result1:5
result2:10
result3:null

除了Mockito.eq()方法,还有多种不同的匹配方式。可以参考org.mockito.ArgumentMatchers这个类中的方法。Mockito类继承该类实现了各种匹配方式。可以互相

# Mocktio匹配特殊值与通用值时出现的冲突

参考之前的匹配不同参数,返回不同值。如果我想对任何参数都默认返回10,在参数等于50时返回50。那么组合使用Mockito.any()和Mockito.eq()是不是就可以了呢?比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
    public void testMockitoReturn() {
        Mockito.when(exampleChildService.parseIntToString(Mockito.eq(50))).thenReturn("50");
        Mockito.when(exampleChildService.parseIntToString(Mockito.anyInt())).thenReturn("10");
        String result1 = exampleFatherService.getDataById(5);
        log.info("result1:{}", result1);
        String result2 = exampleFatherService.getDataById(10);
        log.info("result2:{}", result2);
        String result3 = exampleFatherService.getDataById(50);
        log.info("result3:{}", result3);
    }

然而这段代码的输出如下,不符合期望

1
2
3
result1:10
result2:10
result3:10

但换一下一下Mockito的顺序,反而得到了期望的结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
    public void testMockitoReturn() {
        Mockito.when(exampleChildService.parseIntToString(Mockito.anyInt())).thenReturn("10");
        Mockito.when(exampleChildService.parseIntToString(Mockito.eq(50))).thenReturn("50");
        String result1 = exampleFatherService.getDataById(5);
        log.info("result1:{}", result1);
        String result2 = exampleFatherService.getDataById(10);
        log.info("result2:{}", result2);
        String result3 = exampleFatherService.getDataById(50);
        log.info("result3:{}", result3);
    }

输出为:

1
2
3
result1:10
result2:10
result3:50

# 为什么

很明显,这种用法关联到了Mockito的底层顺序问题。因此首先要了解一下底层结构。我参考深入Java单元测试mock技术学习了一下。

了解到,在mock过程中,首先会生成以下处理器:

  • MockHandler
  • invocationContainer
  • stubbed

那么,猜测可能是这几个处理类在生成过程中少了一些

# 正确的情况

在idea中运行单步调试,查看正确情况中几个类的内容:

正确情况的调试信息

可以看到其中的stubbed列表中正确生成了条件,且顺序为:

  1. “等于50时返回50”
  2. “任意值返回10”

# 错误的情况

再在idea中运行单步调试,同样能看到

错误情况的调试信息

可以看到其中的stubbed列表中同样有相似的条件,但顺序为:

  1. “任意值返回10”
  2. “等于50时返回50”

mock条件正好与正确情况相反,两者在stubbed列表中的顺序都是按照代码顺序入栈的顺序,是否这就是原因呢?

# MockHandlerImpl.handle

找到类org.mockito.internal.handler.MockHandlerImpl,查看其中运行的源码

 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
@Override
    public Object handle(Invocation invocation) throws Throwable {
        if (invocationContainer.hasAnswersForStubbing()) {
            // stubbing voids with doThrow() or doAnswer() style
            InvocationMatcher invocationMatcher =
                    matchersBinder.bindMatchers(
                            mockingProgress().getArgumentMatcherStorage(), invocation);
            invocationContainer.setMethodForStubbing(invocationMatcher);
            return null;
        }
        VerificationMode verificationMode = mockingProgress().pullVerificationMode();

        InvocationMatcher invocationMatcher =
                matchersBinder.bindMatchers(
                        mockingProgress().getArgumentMatcherStorage(), invocation);

        mockingProgress().validateState();

        // if verificationMode is not null then someone is doing verify()
        if (verificationMode != null) {
            // We need to check if verification was started on the correct mock
            // - see VerifyingWithAnExtraCallToADifferentMockTest (bug 138)
            if (MockUtil.areSameMocks(
                    ((MockAwareVerificationMode) verificationMode).getMock(),
                    invocation.getMock())) {
                VerificationDataImpl data =
                        new VerificationDataImpl(invocationContainer, invocationMatcher);
                verificationMode.verify(data);
                return null;
            } else {
                // this means there is an invocation on a different mock. Re-adding verification
                // mode
                // - see VerifyingWithAnExtraCallToADifferentMockTest (bug 138)
                mockingProgress().verificationStarted(verificationMode);
            }
        }

        // prepare invocation for stubbing
        invocationContainer.setInvocationForPotentialStubbing(invocationMatcher);
        OngoingStubbingImpl<T> ongoingStubbing = new OngoingStubbingImpl<T>(invocationContainer);
        mockingProgress().reportOngoingStubbing(ongoingStubbing);

        // look for existing answer for this invocation
        StubbedInvocationMatcher stubbing = invocationContainer.findAnswerFor(invocation);
        // TODO #793 - when completed, we should be able to get rid of the casting below
        notifyStubbedAnswerLookup(
                invocation,
                stubbing,
                invocationContainer.getStubbingsAscending(),
                (CreationSettings) mockSettings);

        if (stubbing != null) {
            stubbing.captureArgumentsFrom(invocation);

            try {
                return stubbing.answer(invocation);
            } finally {
                // Needed so that we correctly isolate stubbings in some scenarios
                // see MockitoStubbedCallInAnswerTest or issue #1279
                mockingProgress().reportOngoingStubbing(ongoingStubbing);
            }
        } else {
            Object ret = mockSettings.getDefaultAnswer().answer(invocation);
            DefaultAnswerValidator.validateReturnValueFor(invocation, ret);

            // Mockito uses it to redo setting invocation for potential stubbing in case of partial
            // mocks / spies.
            // Without it, the real method inside 'when' might have delegated to other self method
            // and overwrite the intended stubbed method with a different one.
            // This means we would be stubbing a wrong method.
            // Typically this would led to runtime exception that validates return type with stubbed
            // method signature.
            invocationContainer.resetInvocationForPotentialStubbing(invocationMatcher);
            return ret;
        }
    }

找到了其中匹配sudbbed的关键函数是findAnswer,找到其源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    public StubbedInvocationMatcher findAnswerFor(Invocation invocation) {
        synchronized (stubbed) {
            for (StubbedInvocationMatcher s : stubbed) {
                if (s.matches(invocation)) {
                    s.markStubUsed(invocation);
                    // TODO we should mark stubbed at the point of stubbing, not at the point where
                    // the stub is being used
                    invocation.markStubbed(new StubInfoImpl(s));
                    return s;
                }
            }
        }

        return null;
    }

可以看到,在匹配mock的表达式时,是在stubbed中从头开始循环匹配,遇到第一个能匹配上的就退出。

因此,在我们的错误写法中,“匹配任意值返回10"排在stubbed中的第一位,因此对这个方法的调用永远会优先匹配到这个条件,而忽略"等于50时返回50"这个条件。所以永远都会返回10

# Mockito多条件匹配的最佳实践

经过上面的实验与观察,我们知道了Mockito中不为人知的秘密:

  1. 编写的mock条件按照代码顺序入栈stubbed列表,即:在stubbed中的排序与在代码中编写设定的顺序相反
  2. mockito匹配时,在stubbed数组中从头到尾排序
  3. 在java代码中最后设定的mock条件会被优先匹配

因此,建议编写mock条件时的最佳实践是:先写匹配访问大的条件,最后再写匹配范围小的精确条件

例如:

1
2
3
4
5
6
// 1.匹配所有的Int,返回0
Mockito.when(exampleChildService.parseIntToString(Mockito.anyInt())).thenReturn("20");
// 2. 匹配大于10的Int,返回10
Mockito.when(exampleChildService.parseIntToString(Mockito.argThat(argument -> argument != null && argument > 10))).thenReturn("10");
// 3. 匹配等于50的Int,返回50
Mockito.when(exampleChildService.parseIntToString(Mockito.eq(50))).thenReturn("50");
使用 Hugo 构建
主题 StackJimmy 设计