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(); }
根因分析
随机失败的核心原因有两个:
- Mockito参数匹配器规则限制:使用的
anyString()匹配器仅匹配非null的String类型参数,一旦传入null值,该打桩规则直接失效,Mock对象会返回布尔类型的默认值false,正好触发旧密码校验失败的分支。 - 测试对象共享导致的状态污染:测试中
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,统一使用配置好的PasswordEncoderBean完成密码加密和校验,对应加密逻辑修改为:foundUser.setPassword(passwordEncoder.encode(newPassword)); - 给所有测试场景显式打桩:即使Mock默认返回值符合预期,也要显式声明打桩规则,比如旧密码不匹配的场景,显式写
when(passwordEncoder.matches(any(), any())).thenReturn(false);,避免默认行为变动导致测试不稳定。
内容的提问来源于stack exchange,提问作者bojackhorseman99
相关产品推荐
相关产品推荐

