Search code examples
javaspringspring-bootauthenticationjunit5

JUnit5 authentication tests not consistant


Problem

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.

Possible reason #1

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.

Possible reason #2

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.

Tests Results

Successfull run

Reruned same tests few seconds after the previous run

Code example

@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);
    }
}

Stack Trace shouldResetPassword()

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)

Stack trace shouldConfrimEmail()

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.


Solution

  • 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.

    1. 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) { ... }
      
    2. 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;
      }
      
    3. 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);
           }
       }