the/experts. Blog

Cover image for Goodbye ArgumentCaptor - Welcome assertArg()
Maik Kingma
Maik Kingma

Posted on

Goodbye ArgumentCaptor - Welcome assertArg()

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
}));
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
}));
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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);
        }));
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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)