背景
最近在编写单元测试时发现了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);
}
|
解释:
- 第一个mockito.when指示mocktio进行打桩,在输入参数等于5时,返回值为5。所以result1等于5
- 第一个mockito.when指示mocktio进行打桩,在输入参数等于10时,返回值为10。所以result2等于10
- 第三个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列表中正确生成了条件,且顺序为:
- “等于50时返回50”
- “任意值返回10”
错误的情况
再在idea中运行单步调试,同样能看到
可以看到其中的stubbed列表中同样有相似的条件,但顺序为:
- “任意值返回10”
- “等于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中不为人知的秘密:
- 编写的mock条件按照代码顺序入栈stubbed列表,即:在stubbed中的排序与在代码中编写设定的顺序相反
- mockito匹配时,在stubbed数组中从头到尾排序
- 在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");
|