Hello! I've been unit testing a spring boot restful practice project i've been working on and there weren't any issues, but when i started testing authentication business logic, some tests would pass on the first run but won't on the next one and vice versa.
The logic i've been testing includes Java Mail Sender
(for email confirmation or forgot password requests) and the methods behind that logic are annoted with @Transactional
and @Async
. I believe the problem is due to tests not being async, however i tried tinkering with @Transactional
and @Async
annotations on my tests, but i was unsuccessfull.
Another reason for this problem might be that im not resetting my mocks, however when i tried using teardown and @AfterEach
to reset mocked instances, it didn't help at all, or maybe im using it wrongly.
Reruned same tests few seconds after the previous run
@Service
@AllArgsConstructor
public class AuthenticationService {
private final UserRepository userRepository;
//TokenService instances are @Qualifier
private final TokenService emailConfirmationTokenService;
private final TokenService resetPasswordTokenService;
private final PasswordEncoder encoder;
@Transactional
public void confirmEmail(String value) {
emailConfirmationTokenService.confirmToken(value);
}
@Transactional
public void resetPassword(String value, ResetPasswordRequestDTO requestDTO) {
User user = resetPasswordTokenService.getUserByToken(value);
resetPasswordTokenService.confirmToken(value);
user.setPassword(encoder.encode(requestDTO.newPassword()));
userRepository.save(user);
}
}
@ExtendWith(MockitoExtension.class)
public class AuthenticationServiceUnitTest {
@Mock
private UserRepository userRepository;
@Mock
private Properties properties;
@Mock
private User user;
@Mock
private AuthenticationHelper authenticationHelper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private EmailService emailService;
@Mock
private ResetPasswordTokenService resetPasswordTokenService;
@Mock
private EmailConfirmationTokenService emailConfirmationTokenService;
@InjectMocks
private AuthenticationService authenticationService;
@AfterEach
void tearDown() {
reset(userRepository, passwordEncoder, emailService,
emailConfirmationTokenService, resetPasswordTokenService,passwordEncoder,
authenticationHelper, properties);
}
@Test
void shouldSendResetPasswordEmail() throws MessagingException {
String recipient = "user@example.com";
User userRequesting = new User();
userRequesting.setEmail(recipient);
String resetURL = "reset-url";
String token = "generated token";
String fullURL = "reset-urlgenerated token";
ForgotPasswordRequestDTO requestDTO = new ForgotPasswordRequestDTO(recipient);
when(userRepository.findUserByEmailIgnoreCase(recipient)).thenReturn(Optional.of(userRequesting));
when(properties.getResetURL()).thenReturn(resetURL);
when(resetPasswordTokenService.generateToken(userRequesting)).thenReturn(token);
authenticationService.forgotPassword(requestDTO);
System.out.println(fullURL);
verify(emailService).sendResetPasswordEmail(userRequesting, fullURL);
}
@Test
void shouldThrowResourceNotFoundWhenProvidedInvalidEmailForForgotPassword() {
String recipient = "notfound@example.com";
ForgotPasswordRequestDTO requestDTO = new ForgotPasswordRequestDTO(recipient);
when(userRepository.findUserByEmailIgnoreCase(recipient)).thenThrow(ResourceNotFoundException.class);
assertThrows(ResourceNotFoundException.class, () -> authenticationService.forgotPassword(requestDTO));
}
@Test
void shouldResetPassword() {
String tokenValue = "validToken";
String newPassword = "newSecurePassword";
User user = new User();
when(resetPasswordTokenService.getUserByToken(tokenValue)).thenReturn(user);
doNothing().when(resetPasswordTokenService).confirmToken(tokenValue);
when(passwordEncoder.encode(newPassword)).thenReturn(newPassword); // Mock password encoding
authenticationService.resetPassword(tokenValue, new ResetPasswordRequestDTO(newPassword, newPassword));
verify(userRepository).save(user);
assertThat(user.getPassword()).isEqualTo(newPassword);
}
@Test
void shouldConfirmEmail() {
String value = "email confirmation";
authenticationService.confirmEmail(value);
verify((emailConfirmationTokenService)).confirmToken(value);
}
}
java.lang.NullPointerException: Cannot invoke "com.cranker.cranker.user.User.setPassword(String)" because "user" is null
at com.cranker.cranker.authentication.AuthenticationService.resetPassword(AuthenticationService.java:82)
at com.cranker.cranker.unit.authentication.AuthenticationServiceUnitTest.shouldResetPassword(AuthenticationServiceUnitTest.java:97)
at java.base/java.lang.reflect.Method.invoke(Method.java:577)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Wanted but not invoked:
emailConfirmationTokenService.confirmToken(
"email confirmation"
);
-> at com.cranker.cranker.token.impl.EmailConfirmationTokenService.confirmToken(EmailConfirmationTokenService.java:30)
Actually, there were zero interactions with this mock.
Wanted but not invoked:
emailConfirmationTokenService.confirmToken(
"email confirmation"
);
-> at com.cranker.cranker.token.impl.EmailConfirmationTokenService.confirmToken(EmailConfirmationTokenService.java:30)
Actually, there were zero interactions with this mock.
I told you to include your source and that you'd get a pretty quick response, as this is a common issue. I looked over your code several times and could not find a single thing that looked amiss so I'll admit that this is not a simple error, but hopefully this answer will help you in the future.
In your case, it turns out that it's a combination of a few things that contribute to the issue, and that is using Lombok to autogenerate your constructor, and using mockito to automatically inject your mocks. Here are the scenarios.
First, let's create a simple class with four dependencies of the same type. Then, let's create a method that performs some operation on each of them.
@Service
@AllArgsConstructor
public class AuthenticationService {
private final PasswordEncoder firstPasswordEncoder;
private final PasswordEncoder secondPasswordEncoder;
private final PasswordEncoder thirdPasswordEncoder;
private final PasswordEncoder fourthPasswordEncoder;
public String encode(String value) {
String firstPass = firstPasswordEncoder.encode(value);
String secondPass = secondPasswordEncoder.encode(firstPass);
String thirdPass = thirdPasswordEncoder.encode(secondPass);
return fourthPasswordEncoder.encode(thirdPass);
}
}
Our test will be simple as well and look something like this:
@ExtendWith(MockitoExtension.class)
class AuthenticationServiceTest {
@Mock
private PasswordEncoder firstPasswordEncoder;
@Mock
private PasswordEncoder secondPasswordEncoder;
@Mock
private PasswordEncoder thirdPasswordEncoder;
@Mock
private PasswordEncoder fourthPasswordEncoder;
@InjectMocks
private AuthenticationService authenticationService;
@Test
public void testEncoding() {
Constructor<?>[] constructors = AuthenticationService.class.getConstructors();
String password = "password";
when(firstPasswordEncoder.encode(password)).thenReturn("passwordTwo");
when(secondPasswordEncoder.encode("passwordTwo")).thenReturn("passwordThree");
when(thirdPasswordEncoder.encode("passwordThree")).thenReturn("password4");
when(fourthPasswordEncoder.encode("password4")).thenReturn("Made it");
String encoded = authenticationService.encode(password);
assertThat(encoded, is(equalTo("Made it")));
}
}
Just like your test, this test fails with
java.lang.AssertionError:
Expected: is "Made it"
but: was null
Expected :is "Made it"
Actual :null
This fails because if Mockito sees two constructor arguments of the same type, then it will use the parameter name to determine which mock to inject. You have your mocks named appropriately in your test, but because the AllArgsConstructor erases the parameter names, Mockito is picking which one to inject. In some cases it gets it correct, in others it is just injecting the same mock instance into both variables. Here are three ways to solve this issue.
Drop the AllArgsConstructor annotation and define your own constructor, thereby preserving the names of the arguments.
public AuthenticationService(UserRepository userRepository, TokenService emailConfirmationTokenService, TokenService resetPasswordTokenService, PasswordEncoder encoder) { ... }
Drop the AllArgsConstructor annotation, remove the final operator from your dependencies, and annotate your service with @Setter to use setter-injection.
@Service
@Setter
public class AuthenticationService {
private UserRepository userRepository;
private TokenService emailConfirmationTokenService;
private TokenService resetPasswordTokenService;
private PasswordEncoder encoder;
}
Use a single mock to define the behavior for both emailConfirmationTokenService and resetPasswordTokenService. Because mockito is perfectly fine injecting the same mock multiple times, only define one mock for both.
@ExtendWith(MockitoExtension.class)
public class AuthenticationServiceUnitTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private ResetPasswordTokenService myOneAndOnlyTokenMockThatIsInjectedIntoTwoProperties;
@InjectMocks
private AuthenticationService authenticationService;
@Test
void shouldResetPassword() {
String tokenValue = "validToken";
String newPassword = "newSecurePassword";
User user = new User();
when(myOneAndOnlyTokenMockThatIsInjectedIntoTwoProperties.getUserByToken(tokenValue)).thenReturn(user);
when(passwordEncoder.encode(newPassword)).thenReturn(newPassword); // Mock password encoding
authenticationService.resetPassword(tokenValue, new ResetPasswordRequestDTO(newPassword, newPassword));
// I like to just capture the argument to mock and assert on the captured instance
ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userArgumentCaptor.capture());
assertThat(userArgumentCaptor.getValue().getPassword(), is(equalTo("newSecurePassword")));
}
@Test
void shouldConfirmEmail() {
String value = "email confirmation";
authenticationService.confirmEmail(value);
verify((myOneAndOnlyTokenMockThatIsInjectedIntoTwoProperties)).confirmToken(value);
}
}