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

Mockito.when打桩随机失效导致changePassword单元测试偶发失败

问题背景

测试UserService类的changePassword方法时出现偶发失败:执行全量测试包时,成功场景用例changePasswordSuccessfully随机报错,错误为意外触发旧密码不通过的校验分支,抛出400 BAD_REQUEST异常,打桩规则when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);看起来未生效。

相关业务代码

@Service
public class UserService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final BCryptPasswordEncoder bCryptPasswordEncoder;

public UserService(UserConverter userConverter, UserRepository userRepository,
                   PasswordEncoder passwordEncoder, BCryptPasswordEncoder bCryptPasswordEncoder) {
    this.userConverter = userConverter;
    this.userRepository = userRepository;
    this.passwordEncoder = passwordEncoder;
    this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}

public User changePassword(String email, String oldPassword, String newPassword, String modifiedBy) {
    User foundUser = userRepository.findByEmail(email).orElse(null);

    if (Objects.isNull(foundUser)) {
        String errorMessage = String.format("User lookup failed for the email '%s'", email);
        logger.warn(errorMessage);
        throw new EntityNotFoundException(errorMessage);
    }

    if (!passwordEncoder.matches(oldPassword, foundUser.getPassword())) {
        String errorMessage = "Old password doesn't match";
        logger.warn(errorMessage);
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, errorMessage);
    }

    foundUser.setPassword(bCryptPasswordEncoder.encode(newPassword));
    foundUser.setModifiedBy(modifiedBy);

    return userRepository.save(foundUser);
} // 其他无关方法已省略

相关测试代码

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

@Mock
private UserRepository userRepository;
@Mock
private UserConverter userConverter;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private BCryptPasswordEncoder bCryptPasswordEncoder;
@InjectMocks
private UserService userService;

@Test
public void changePasswordSuccessfully() throws Exception {
    String email = "bojack@bojack.com";
    String oldPassword = "Bojack1234";
    String newPassword = "Bojack12345";
    String modifiedBy = "peanutbutter@hollywoo.com";

    User userToBeReturned = new User();
    userToBeReturned.setPassword(newPassword);
    userToBeReturned.setModifiedBy(modifiedBy);

    doReturn(Optional.ofNullable(genericUser))
            .when(userRepository).findByEmail(anyString());
    doReturn(userToBeReturned)
            .when(userRepository).save(any(User.class));
    when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
    when(bCryptPasswordEncoder.encode(anyString())).thenReturn(newPassword);

    User returnedUser = userService.changePassword(email, oldPassword, newPassword, modifiedBy);

    assertEquals(userToBeReturned.getModifiedBy(), returnedUser.getModifiedBy());
    assertEquals(userToBeReturned.getPassword(), returnedUser.getPassword());
}

@Test
public void oldPasswordDoesNotMatchWhenChangingPassword() throws Exception {
    String email = "bojack@bojack.com";
    String oldPassword = "Bojack1234";
    String newPassword = "Bojack12345";
    String modifiedBy = "peanutbutter@hollywoo.com";

    User userToBeReturned = new User();
    userToBeReturned.setPassword(newPassword);
    userToBeReturned.setModifiedBy(modifiedBy);

    doReturn(Optional.ofNullable(genericUser))
            .when(userRepository).findByEmail(anyString());

    assertThrows(ResponseStatusException.class, () -> {
        userService.changePassword(email, oldPassword, newPassword, modifiedBy);
    });
 } // 其他无关测试已省略
}

报错信息

org.springframework.web.server.ResponseStatusException: 400 BAD_REQUEST "Old password doesn't match"

at cr.lasbrumas.lasbrumassource.user.UserService.changePassword(UserService.java:62)
at cr.lasbrumas.lasbrumassource.user.UserServiceTest.changePasswordSuccessfully(UserServiceTest.java:128)

PasswordEncoder Bean配置

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
根因分析

随机失败的核心原因有两个:

  1. Mockito参数匹配器规则限制:使用的anyString()匹配器仅匹配非null的String类型参数,一旦传入null值,该打桩规则直接失效,Mock对象会返回布尔类型的默认值false,正好触发旧密码校验失败的分支。
  2. 测试对象共享导致的状态污染:测试中findByEmail返回的genericUser是多个测试用例共享的可变对象,JUnit 5默认不保证测试方法执行顺序,当其他测试用例修改了genericUser的password属性为null时,传入passwordEncoder.matches()的第二个参数就是null,直接导致打桩失效,这就是失败随机出现的原因。

另外代码还存在两个不稳定隐患:

  • 业务层同时注入PasswordEncoder接口和其实现类BCryptPasswordEncoder,本身属于冗余依赖,Mock时容易出现注入逻辑混乱,也不符合统一用DelegatingPasswordEncoder的配置初衷。
  • 失败场景测试没有显式给passwordEncoder.matches()打桩,依赖Mock的默认返回值,后续Mockito版本调整默认行为时会导致测试意外失败。
修复方案
  • 替换宽泛的参数匹配器,优先使用精确匹配:把成功场景的打桩改为精确匹配入参,或者用支持null的any()替换anyString(),推荐写法示例:
    // 每个测试单独构造查询到的用户,不使用共享对象
    User foundUser = new User();
    foundUser.setPassword("encryptedOldPassword");
    when(userRepository.findByEmail(eq(email))).thenReturn(Optional.of(foundUser));
    // 精确匹配入参,避免null导致打桩失效
    when(passwordEncoder.matches(eq(oldPassword), eq(foundUser.getPassword()))).thenReturn(true);
    
  • 移除测试用例之间共享的可变测试对象,每个测试方法单独构造自己需要的User实体,完全隔离测试状态,避免其他用例修改对象属性影响当前测试。
  • 优化业务层依赖:删除UserService中注入的BCryptPasswordEncoder,统一使用配置好的PasswordEncoder Bean完成密码加密和校验,对应加密逻辑修改为:
    foundUser.setPassword(passwordEncoder.encode(newPassword));
    
  • 给所有测试场景显式打桩:即使Mock默认返回值符合预期,也要显式声明打桩规则,比如旧密码不匹配的场景,显式写when(passwordEncoder.matches(any(), any())).thenReturn(false);,避免默认行为变动导致测试不稳定。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.27 20:09:17