By Ashley Waldron
All of the source code for this series can be found in this repository. Specifically, the code used throughout this item can be found here and its corresponding test classes found here.
In the previous items we created the beginnings of a default happy path unit test. But there is much more to do in order to get this test up to scratch. In this item we’ll talk about the use of ArgumentCaptor in unit test mock setups and how we can update our test to be more robust by using them. One problem we have right now is that: of the calls that the code makes to the external classes, 2 of those methods return void and 1 takes an object (userEnity) that we have no access to because it’s created inside the code. So we can’t use the normal strict mock setups that we talked about in item 2.
In item 1 we mentioned that the test had the following 3 problems that we’ll repeat here again:
- We didn’t make sure that the
userEntityobject which was saved contained the correct values for each field. We’ve used theany()argument matcher on line 56, but as mentioned in item 2 that matcher should be used very infrequently. - We didn’t assert that
EventBroadcaster.broadcast()was called with the correct values. - We didn’t assert that
MarketingEmailService.registerSubscriber()was called with the correct values.
Let’s update the code to solve problems 1 and 2 [See Item3UserServiceTest.createSuccessItem3a()]:
package com.effective.unit.tests.service.item3;
import com.effective.unit.tests.domain.CreateUserProfileRequest;
import com.effective.unit.tests.domain.CreateUserProfileResponse;
import com.effective.unit.tests.domain.UserEntity;
import com.effective.unit.tests.domain.enums.EventType;
import com.effective.unit.tests.repositories.UserRepository;
import com.effective.unit.tests.service.EmployeeNumberGenerator;
import com.effective.unit.tests.service.EventBroadcaster;
import com.effective.unit.tests.service.MarketingEmailService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.text.MatchesPattern.matchesPattern;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
@ExtendWith(MockitoExtension.class)
public class Item3UserServiceTest {
private static final String UUID_PATTERN = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
@InjectMocks
private Item3UserService item3UserService;
@Mock
private UserRepository userRepository;
@Mock
private EventBroadcaster eventBroadcaster;
@Mock
private MarketingEmailService emailService;
@Mock
private EmployeeNumberGenerator employeeNumberGenerator;
@Captor
private ArgumentCaptor<UserEntity> userEntityArgumentCaptor;
private UserEntity mockSavedUserEntity = new UserEntity();
@Test
void createSuccessItem3a() {
mockSavedUserEntity.setEmployeeNumber("EUS1002");
mockSavedUserEntity.setEmailAddress("TestEmailAddress@test.com");
mockSavedUserEntity.setId(UUID.randomUUID().toString());
given(userRepository.save(userEntityArgumentCaptor.capture())).willReturn(mockSavedUserEntity);
given(employeeNumberGenerator.generate("US-EAST")).willReturn("EUS1002");
CreateUserProfileRequest createUserProfileRequest = new CreateUserProfileRequest();
createUserProfileRequest.setRegion("TestEmailAddress@test.com");
createUserProfileRequest.setRegion("US-EAST");
createUserProfileRequest.shouldReceiveMarketingEmails(true);
CreateUserProfileResponse createUserProfileResponse = item3UserService.create(createUserProfileRequest);
assertThat(createUserProfileResponse.getEmployeeNumber(), is(equalTo("EUS1002")));
assertThat(createUserProfileResponse.getRegion(), is(equalTo(createUserProfileRequest.getRegion())));
assertThat(createUserProfileResponse.getEmailAddress(), is(equalTo(createUserProfileRequest.getEmailAddress())));
assertThat(createUserProfileResponse.getId(), matchesPattern(UUID_PATTERN));
assertThat(userEntityArgumentCaptor.getValue().getEmployeeNumber(), is("EUS1002"));
assertThat(userEntityArgumentCaptor.getValue().getEmailAddress(), is(createUserProfileRequest.getEmailAddress()));
assertThat(userEntityArgumentCaptor.getValue().getRegion(), is(equalTo(createUserProfileRequest.getRegion())));
assertThat(userEntityArgumentCaptor.getValue().getId(), matchesPattern(UUID_PATTERN));
then(eventBroadcaster).should().broadcast(EventType.USER_REGISTRATION, mockSavedUserEntity);
}
}- We’ve added an
ArgumentCaptoron line 51 for theuserEntityobject that gets passed to theUserRepository.save()method. We’ve then replaced theany()matcher on Line 60 with the capture of this value. And from line 74 we use this to assert that theuserEntityobject that was passed contained the correct values. - We’ve also added a verification on line 79 to verify that the
EventBroadcaster.broadcast()method was called with the correct values.
This test is now much stronger in terms of what it validates since we can verify exactly what was passed to the UserRepository.save() method. And of course we’ve also added the missing verification for the EventBroadcaster mock.
Alternative approach
There is another way I’ve seen developers do this but I don’t recommend it. This way uses the approach whereby we replace the mock setup above with something like the following [See Item3UserServiceTest.createSuccessItem3b()]:
final AtomicReference<UserEntity> passedInUserEntity = new AtomicReference<>();
given(userRepository.save(userEntityArgumentCaptor.capture())).willAnswer(invocationOnMock -> {
passedInUserEntity.set((UserEntity) invocationOnMock.getArguments()[0]);
return passedInUserEntity.get();
});This would then make the UserRepository.save() method simply reply with the same userEntity object that was passed in while still allowing us to use the ArgumentCaptor to assert that the userEntity passed to the save method contained the correct values. This seems like it would be easier to work with and would save us from having to set up the mockSavedUserEntity object in the unit test. However, in this scenario it’s an inferior approach which can mask potential flaws in the code. To explain why, let’s dig into the JPA specification:
For those familiar with the JPA Specification (as implemented by Springboot JpaRepository, etc) you’ll have guessed that the UserRepository.save() method corresponds to using something like the JpaRepository or related interfaces for carrying out database operations. The UserRepository code is of course a repository interface which extends the Springboot JpaRepository interface. If you’re working through the sample code you’ll see that this is not actually the case but that’s only because I wanted to keep the code lightweight and as dependency free as possible, in the real world the UserRepository interface would extend org.springframework.data.jpa.repository.JpaRepository. The reason I’m mentioning this is because the first approach above actually guards against a potential defect that can pop up when working with frameworks or ORMs that implement the JPA specification (Springboot, Hibernate, etc.). For example, the Springboot JpaRepository.save() method is documented to work as follows:
Saving an entity can be performed with the CrudRepository.save(…) method. It persists or merges the given entity by using the underlying JPA EntityManager. If the entity has not yet been persisted, Spring Data JPA saves the entity with a call to the entityManager.persist(…) method. Otherwise, it calls the entityManager.merge(…) method.
Furthermore, the JPA specification defines the merge() method as: merging the supplied entity with the existing entity from the database, saving it and returning a new entity representing the newly updated entity. This means that the object passed into the save() method should basically be discarded and the returned entity should be used going forward because they are possibly not the same object. You can see in our code we do this by declaring the new variable createdUserEntity and assigning it the value returned from the save method so we can work with createdUserEntity going forward rather than the originally created userEntity object.
UserEntity createdUserEntity = userRepository.save(userEntity);So if we were to use this alternative mock setup the test would look something like the following (and let’s also add the verification for the EmailService mock to solve problem 3 above) [See Item3UserServiceTest.createSuccessItem3b()]:
@Test
void createSuccessItem3b() {
final AtomicReference<UserEntity> passedInUserEntity = new AtomicReference<>();
given(userRepository.save(userEntityArgumentCaptor.capture())).willAnswer(invocationOnMock -> {
passedInUserEntity.set((UserEntity) invocationOnMock.getArguments()[0]);
return passedInUserEntity.get();
});
given(employeeNumberGenerator.generate("US-EAST")).willReturn("EUS1002");
CreateUserProfileRequest createUserProfileRequest = new CreateUserProfileRequest();
createUserProfileRequest.setRegion("TestEmailAddress@test.com");
createUserProfileRequest.setRegion("US-EAST");
createUserProfileRequest.shouldReceiveMarketingEmails(true);
CreateUserProfileResponse createUserProfileResponse = item3UserService.create(createUserProfileRequest);
assertThat(createUserProfileResponse.getEmployeeNumber(), is(equalTo("EUS1002")));
assertThat(createUserProfileResponse.getRegion(), is(equalTo(createUserProfileRequest.getRegion())));
assertThat(createUserProfileResponse.getEmailAddress(), is(equalTo(createUserProfileRequest.getEmailAddress())));
assertThat(createUserProfileResponse.getId(), matchesPattern(UUID_PATTERN));
assertThat(userEntityArgumentCaptor.getValue().getEmployeeNumber(), is("EUS1002"));
assertThat(userEntityArgumentCaptor.getValue().getEmailAddress(), is(createUserProfileRequest.getEmailAddress()));
assertThat(userEntityArgumentCaptor.getValue().getRegion(), is(equalTo(createUserProfileRequest.getRegion())));
assertThat(userEntityArgumentCaptor.getValue().getId(), matchesPattern(UUID_PATTERN));
then(eventBroadcaster).should().broadcast(EventType.USER_REGISTRATION, passedInUserEntity.get());
then(emailService).should().registerSubscriber(passedInUserEntity.get());
}Apart from the fact that this test doesn’t exactly look very clean the main problem with it is that it passes when it should in fact fail! As mentioned in Item 1, there’s a bug in the code which this approach doesn’t catch.
To see this bug in action, revert the test back to the first approach at the start of this item [Item3UserServiceTest.createSuccessItem3a()] and add in the final verification for the EmailService.registerSubscriber() mock again. So the final test looks like this [See Item3UserServiceTest.createSuccessItem3c()]:
@Test
void createSuccessItem3c() {
mockSavedUserEntity.setEmployeeNumber("TestUsername");
mockSavedUserEntity.setEmailAddress("TestEmailAddress@test.com");
mockSavedUserEntity.setId(UUID.randomUUID().toString());
given(userRepository.save(userEntityArgumentCaptor.capture())).willReturn(mockSavedUserEntity);
CreateUserProfileRequest createUserProfileRequest = new CreateUserProfileRequest();
createUserProfileRequest.setRegion("TestEmailAddress@test.com");
createUserProfileRequest.setRegion("US-EAST");
createUserProfileRequest.shouldReceiveMarketingEmails(true);
CreateUserProfileResponse createUserProfileResponse = item3UserService.create(createUserProfileRequest);
assertThat(createUserProfileResponse.getEmployeeNumber(), is(equalTo(createUserProfileRequest.getRegion())));
assertThat(createUserProfileResponse.getEmailAddress(), is(equalTo(createUserProfileRequest.getEmailAddress())));
assertThat(createUserProfileResponse.getId(), matchesPattern(UUID_PATTERN));
assertThat(userEntityArgumentCaptor.getValue().getEmployeeNumber(), is(createUserProfileRequest.getRegion()));
assertThat(userEntityArgumentCaptor.getValue().getEmailAddress(), is(createUserProfileRequest.getEmailAddress()));
assertThat(userEntityArgumentCaptor.getValue().getId(), matchesPattern(UUID_PATTERN));
then(eventBroadcaster).should().broadcast(EventType.USER_REGISTRATION, mockSavedUserEntity);
then(emailService).should().registerSubscriber(mockSavedUserEntity);
}If you run this test you’ll see that it now fails!!! Can you see why? Hint: it’s related to what was discussed above about how the JPA specification works.
The following line in UserService passes in the original userEntity object to the EmailService.registerSubscriber() method instead of passing in the newly created createdUserEntity object which we were supposed to [See Item3UserService.create()].
marketingEmailService.registerSubscriber(userEntity);This is an easy mistake to make and we can see that returning the mockSavedUserEntity in the mock setup instead of using the alternative invocationOnMock approach is superior for guarding against any issues like this. The alternative ‘invocationOnMock’ approach doesn’t catch this issue.
So now that we’ve caught this bug we can fix it by changing that line to the following:
marketingEmailService.registerSubscriber(createdUserEntity);Run the test again and verify that it now passes.
It’s important to note that the earlier discussion on JPA relates to any methods which accept a parameter and return that same parameter type from the method, regardless of whether or not JPA is involved. We used JPA to elaborate on this point because it’s a very common approach in modern Java service development.
Conceptual Summary
One of the main takeaways from this series so far should be the conception developers have of inputs and outputs. When we are testing something (whether a service API call or a method on a class) we tend to focus only on a single output – the thing that’s returned from the method (or API call). But in the majority of cases the thing that gets returned is only 1 of several outputs. Think about the UserService class we have been working with, it actually has up to 5 different outputs, not just 1. The most obvious output is of course the return value of the create() method. But there is also the call to MarketingEmailService, the call to UserRepository, the call the EventBroadcaster and the call to MarketingEmailService. Those are all outputs too! And just like you must assert the value that is returned from the method (the most obvious output), you must also assert the value(s) of each of the other outputs also. Likewise if we were testing an API call we wouldn’t just assert the API response value (the most obvious output), we would also assert any database changes and any calls that go to external services (using a tool like wiremock) because those are all outputs too.
But we’re not done yet. As you’ve probably guessed by now there are more ways we can improve this unit test which we’ll continue with in the next item.