Is this really Goodbye or just a 'See you later'?
As a Java developer, you're probably familiar with the Mockito framework, a popular open-source Java testing library that enables developers to write unit tests in an easy and efficient way. If you're new to Mockito, it provides a wide range of features to help you verify that your code behaves as expected.
One of those features is ArgumentCaptor
, a powerful tool that allows you to capture arguments passed to a method call and perform assertions on them. This feature is particularly useful when testing methods with complex argument structures, where traditional assertions can quickly become unwieldy and difficult to read.
However, Mockito has recently introduced a new feature that could make ArgumentCaptor
a thing of the past. Say hello to assertArg(), a new and elegant way from the latest Mockito release 5.3.0 to verify arguments passed to a method call.
The basic syntax for assertArg()
is simple and intuitive:
verify(mockObject).methodName(assertArg(arg -> {
// perform assertions on arg
}));
ArgumentCaptors - How we did it until now
The following code snippet uses Mockito's ArgumentCaptor
to capture an argument passed to the save method of a mocked AuthorRepository
. It then retrieves the captured argument and asserts that its name field is equal to "John Doe".
// given
AuthorRepository authorRepository = mock(AuthorRepository.class);
AuthorService authorService = new AuthorService(authorRepository);
// when
authorService.registerAuthor("John", "Doe");
// then
ArgumentCaptor<Author> captor = ArgumentCaptor.forClass(Author.class);
verify(authorRepository).save(captor.capture());
Author actual = captor.getValue();
assertThat(author.name()).isEqualTo("John Doe");
This is maybe the most basic example of how to use ArgumentCaptors
. So let's have a look at what assertArg() brings to the ring.
assertArg() - easy and intuitive
With the introduction of assertArg() we can refactor the given code snippet and actually get rid of the ArgumentCaptor
pattern entirely:
// given
AuthorRepository authorRepository = mock(AuthorRepository.class);
AuthorService authorService = new AuthorService(authorRepository);
// when
authorService.registerAuthor("John", "Doe");
// then
verify(authorRepository).save(assertArg(author -> {
assertThat(author.name()).isEqualTo("John Doe");
}));
Just as the ArgumentCaptor
code snippet, this code verifies that an argument passed to the save method of a mocked AuthorRepository
has the expected value, but we now use assertArg
. It asserts that the argument's name field is equal to "John Doe" by passing a lambda expression to assertArg
.
This pattern feels a lot more intuitive and reduces the lines of code we need to write for asserting the exact same thing.
But what will happen if we apply this pattern to somewhat more complex test scenarios?
Limitations
So is this really the end of ArgumentCaptors? Can the feature be deprecated? The short answer is no!
Multiple Invocations
Let us assume for the sake of the argument that we have a method registerAuthorByName
that calls the same method on a class, in this case AuthorDataService
, two times:
public void registerAuthorByName(String firstName, String lastName) {
Author author = Author.create(firstName, lastName);
authorDataService.save(author);
Author clonedAuthor = Author.create(firstName + " " + lastName, "clone");
authorDataService.save(clonedAuthor);
}
In this scenario, the ArgumentCaptor
test would look something like this:
@Test
void registerAuthorByName() {
// given
var expectedAuthor = new Author("firstName", "lastName");
var expectedClonedAuthor = new Author("firstName lastName", "clone");
ArgumentCaptor<Author> argumentCaptor = ArgumentCaptor.forClass(Author.class);
// when
authorFlow.registerAuthorByName("firstName", "lastName");
// then
verify(authorDataService, times(2)).save(argumentCaptor.capture());
var actualAuthors = argumentCaptor.getAllValues();
assertThat(actualAuthors).usingRecursiveFieldByFieldElementComparator().containsExactlyInAnyOrder(expectedAuthor, expectedClonedAuthor);
}
And it will pass with flying colours. Let's refactor the test to use assertArg()
:
@Test
void registerAuthorByName() {
// given
var expectedAuthor = new Author("firstName", "lastName");
var expectedClonedAuthor = new Author("firstName lastName", "clone");
// when
authorFlow.registerAuthorByName("firstName", "lastName");
// then
verify(authorDataService).save(assertArg(actualAuthor -> {
// fails on second invocation's parameter
assertThat(actualAuthor).usingRecursiveComparison().isEqualTo(expectedAuthor);
}));
// never reaches this point of execution
verify(authorDataService).save(assertArg(actualAuthor -> {
assertThat(actualAuthor).usingRecursiveComparison().isEqualTo(expectedClonedAuthor);
}));
}
Spoiler-alert: This test will not pass!
When debugging this test execution, it seems that the first verify + assertArg() combo is invoked twice, one time with the author, which passes the assertion, and a second time with our clone author, which will fail the assertion. No need to mention, that it will never reach our second verify block.
This leaves us with the conclusion that we can nicely use assertArg
for one-time method invocations, but it will fail us when a method is called multiple times in the test scope.
Referencing a captured argument
We might encounter a scenario where we want to assert that a captured argument's field from a first method invocation is still the same at a later method invocation:
public Author registerAuthorByName(String firstName, String lastName) {
Author author = Author.create(1L, firstName, lastName);
authorDataService.save(author);
return authorDataService.findById(1L);
}
And the corresponding test patterns using ArgumentCaptor
and assertArg
respectively:
// ArgumentCaptor
ArgumentCaptor<Author> captor = ArgumentCaptor.forClass(Author.class);
verify(authorDataService).save(captor.capture());
verify(authorDataService).findById(eq(captor.getValue().getId()));
// assertArg + AtomicReference
AtomicReference<Author> reference = new AtomicReference<>();
verify(authorDataService).save(assertArg(arg -> reference.set(arg)));
verify(authorDataService).findById(eq(reference.get().getId()));
In this test scenario, both test patterns will pass. The only thing we need to add to the mix is AtomicReferences
that are needed to write our captured result into a variable outside our lambda scope.
If we compare the ArgumentCaptor
vs. assertArg + AtomicReference
patterns, it feels the ArgumentCaptor
one is a little less verbose and somewhat more approachable when reading it. On the other hand, if you are used to working with lambdas and functional programming patterns, then the assertArg + AtomicReference
solution will provide you a similarly powerful approach to solving the test scenario at hand. Which one to choose is up to you.
Summary
In conclusion, Mockito's new assertArg()
feature offers an elegant and intuitive way to verify arguments passed to a method call, reducing the amount of code needed in certain test scenarios. However, it is not the definitive replacement for ArgumentCaptor
, as it falls short in scenarios where a method is called multiple times in the test scope. When considering which approach to use, developers should carefully evaluate the test scenario at hand and choose the pattern that best suits their needs, whether it be the traditional ArgumentCaptor
or the new assertArg()
feature combined with AtomicReference
when referencing a captured argument across multiple invocations.
Discussion (0)