You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Mockito测试中ArgumentCaptor偶发失败问题求助

问题:Mockito测试偶现调用次数不匹配

环境信息

  • Java 17
  • Spring Boot 2.7.5(通过spring-boot-starter-test引入JUnit Jupiter和Mockito)

待测试方法

public void verifyUserIsClubAdminOfAnyClub(@NonNull String username, @NonNull Set<Long> clubIds) {
    if (clubIds.stream()
            .map(clubAdminService::getAdminsOfClub)
            .flatMap(Set::stream)
            .noneMatch(username::equals)) {
        throw new AccessDeniedException("...");
    }
}

其中getAdminsOfClub方法返回Set<String>类型。

测试用例

@Test
void testVerifyUserIsClubAdminOfAnyClubSuccess() {
    String username = "username";
    Set<Long> clubIds = Set.of(1L, 2L);
    when(clubAdminService.getAdminsOfClub(1L)).thenReturn(Set.of("some user", "another user"));
    when(clubAdminService.getAdminsOfClub(2L)).thenReturn(Set.of("username", "some user"));

    ArgumentCaptor<Long> clubIdArgumentCaptor = ArgumentCaptor.forClass(Long.class);
    authorizationService.verifyUserIsClubAdminOfAnyClub(username, clubIds);
    verify(clubAdminService, times(2)).getAdminsOfClub(clubIdArgumentCaptor.capture());
    assertThat(clubIdArgumentCaptor.getAllValues()).containsExactlyInAnyOrder(1L, 2L);
}

问题现象

  • 该测试在IntelliJ IDEA中全部通过,但在Eclipse中仅约80%的运行次数能通过(Eclipse测试执行速度约为IDEA的一半)
  • 失败时的堆栈信息:
org.mockito.exceptions.verification.TooFewActualInvocations: 
clubAdminService.getAdminsOfClub(
    <Capturing argument>
);
Wanted 2 times:
-> at de.tonypsilon.bmm.backend.security.rnr.service.AuthorizationServiceTest.testVerifyUserIsClubAdminOfAnyClubSuccess(AuthorizationServiceTest.java:39)
But was 1 time:
-> at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)

    at de.tonypsilon.bmm.backend.security.rnr.service.AuthorizationServiceTest.testVerifyUserIsClubAdminOfAnyClubSuccess(AuthorizationServiceTest.java:39)
    ...(省略后续堆栈信息)

原因分析

问题核心是Stream的短路特性:noneMatch属于短路操作,一旦找到匹配元素(即某俱乐部的管理员集合包含目标用户名),就会立即终止流的遍历,不会处理后续的clubId。

而Set的遍历顺序不固定,测试执行时会出现两种情况:

  • 如果先遍历2L,调用getAdminsOfClub(2L)后发现包含username,流直接终止,仅调用1次方法
  • 如果先遍历1L,调用后不匹配,才会继续遍历2L,此时调用2次方法

测试中硬编码要求调用times(2),导致遍历顺序不同时出现偶现失败。

解决方案

方案1:修改测试,兼容短路场景

不再强制固定调用次数,转而验证核心业务逻辑:方法执行不抛出异常,且至少有一次调用的clubId对应管理员集合包含目标用户名:

@Test
void testVerifyUserIsClubAdminOfAnyClubSuccess() {
    String username = "username";
    Set<Long> clubIds = Set.of(1L, 2L);
    when(clubAdminService.getAdminsOfClub(1L)).thenReturn(Set.of("some user", "another user"));
    when(clubAdminService.getAdminsOfClub(2L)).thenReturn(Set.of("username", "some user"));

    ArgumentCaptor<Long> clubIdArgumentCaptor = ArgumentCaptor.forClass(Long.class);
    
    // 验证方法执行不抛出异常
    assertDoesNotThrow(() -> authorizationService.verifyUserIsClubAdminOfAnyClub(username, clubIds));
    
    // 捕获所有调用的clubId,允许至少一次调用
    verify(clubAdminService, atLeastOnce()).getAdminsOfClub(clubIdArgumentCaptor.capture());
    
    // 验证捕获的clubId中,至少有一个对应的管理员集合包含目标用户名
    assertThat(clubIdArgumentCaptor.getAllValues())
            .anyMatch(clubId -> {
                if (clubId.equals(1L)) {
                    return Set.of("some user", "another user").contains(username);
                } else if (clubId.equals(2L)) {
                    return Set.of("username", "some user").contains(username);
                }
                return false;
            });
}

方案2:拆分测试场景,覆盖所有分支

分别测试两种短路情况,确保所有代码分支都被覆盖:

// 场景1:第一个俱乐部匹配,仅调用一次方法
@Test
void testVerifyUserIsClubAdminOfAnyClubFirstClubMatches() {
    String username = "username";
    Set<Long> clubIds = Set.of(2L, 1L); // 明确指定顺序,让匹配的clubId先遍历
    when(clubAdminService.getAdminsOfClub(2L)).thenReturn(Set.of("username", "some user"));
    when(clubAdminService.getAdminsOfClub(1L)).thenReturn(Set.of("some user", "another user"));

    ArgumentCaptor<Long> clubIdArgumentCaptor = ArgumentCaptor.forClass(Long.class);
    authorizationService.verifyUserIsClubAdminOfAnyClub(username, clubIds);
    
    verify(clubAdminService, times(1)).getAdminsOfClub(clubIdArgumentCaptor.capture());
    assertThat(clubIdArgumentCaptor.getAllValues()).containsExactly(2L);
}

// 场景2:第二个俱乐部匹配,调用两次方法
@Test
void testVerifyUserIsClubAdminOfAnyClubSecondClubMatches() {
    String username = "username";
    Set<Long> clubIds = Set.of(1L, 2L);
    when(clubAdminService.getAdminsOfClub(1L)).thenReturn(Set.of("some user", "another user"));
    when(clubAdminService.getAdminsOfClub(2L)).thenReturn(Set.of("username", "some user"));

    ArgumentCaptor<Long> clubIdArgumentCaptor = ArgumentCaptor.forClass(Long.class);
    authorizationService.verifyUserIsClubAdminOfAnyClub(username, clubIds);
    
    verify(clubAdminService, times(2)).getAdminsOfClub(clubIdArgumentCaptor.capture());
    assertThat(clubIdArgumentCaptor.getAllValues()).containsExactlyInAnyOrder(1L, 2L);
}

方案3:修改待测试方法(不推荐)

如果业务允许,可以放弃短路优化强制遍历所有clubIds,但这会降低性能,仅在特殊场景下考虑:

public void verifyUserIsClubAdminOfAnyClub(@NonNull String username, @NonNull Set<Long> clubIds) {
    // 用collect强制遍历所有元素,放弃短路
    boolean isAdmin = clubIds.stream()
            .map(clubAdminService::getAdminsOfClub)
            .flatMap(Set::stream)
            .collect(Collectors.toSet())
            .contains(username);
    if (!isAdmin) {
        throw new AccessDeniedException("...");
    }
}

内容的提问来源于stack exchange,提问作者Tonypsilon

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 16:16:06