
Consistent Tests with Argument Capture
Discover how to guarantee data consistency in your tests using Mockito’s Argument Capture.
I’ve seen many developers put aside an important part of our job: writing TESTS!
I know that for many developers, the real test is production. Others think that tests are for QA professionals, or they rationalize that if a backend request works, there’s no need to write tests. I’ve heard many incredible excuses to avoid writing unit tests. Nowadays, with the advent of generative AI, many developers just let the AI write their tests, and if the tests pass, they consider the task done and ready for review. I won’t waste time listing why you shouldn’t let a generative AI write your code, or why you should always review AI-generated code. If you don’t know why, life will eventually teach you. Unit tests help you catch errors early, reduce costs, improve code quality, enhance maintainability, give you confidence in your code, increase your productivity, and believe it or not, make your development cycle faster. I’m tired of seeing developers’ tasks sent back to development because QA finds bugs that should have been easily caught with a simple unit test.
Even some production bugs, normally can be simply avoided with a good coverage of tests following the widely spread principle of FIRST:
- Fast: Tests should execute quickly to provide rapid feedback during development.
- Isolated/Independent: Each test should be independent and not rely on the state of other tests or external factors.
- Repeatable: Tests should be consistently repeatable, producing the same results every time they are executed, regardless of the environment.
- Self-validating: Tests should be able to automatically determine if they pass or fail, without manual intervention. There should be clear indicators of success or failure.
- Timely: Tests should be written and executed in a timely manner, ideally alongside the code they are testing.
Today I will focus on a specific case of unit tests. It’s common, and in my opinion, good practice, to use mocks when testing a service layer. In the context of Clean Architecture or Hexagonal Architecture, where each layer has its responsibility well-defined, unit tests should test each layer in an isolated way. Let’s dive into a specific and widespread use case.
I’ll present a simple project for storing characters—be they from comics, TV shows, or elsewhere. For this project, I’ll be using Java 24 and Quarkus with a native JVM named Mandrel.
Why Quarkus?
Quarkus is a modern, efficient cloud-native, Kubernetes-native ecosystem. It’s fast, lightweight, great for developing high-performance applications, has a very fast startup, and finally, because I like it.
So, in this project, we need to test the CreateCharacter
use case in isolation, as the persistence layer has already been tested.
Why should we test the creation use case in isolation?
1. Focus on business rules.
The primary goal of our use case CreateCharacter
is to orchestrate the flow of character creation. This includes validating input data,
checking for duplicate characters, building the entity with a unique ID, and finally, telling the repository to persist the data.
2. Speed and Reliability
Unit tests that use mocks are significantly faster than integration tests that rely on a real database. A fast test suite allows you to run tests frequently, enabling an agile development cycle. Moreover, the reliability of your test increases because it’s no longer affected by external factors like database connection issues. Does this mean we should never deal with a real database in a unit test? No. Remember, our persistence layer is already tested separately to ensure it interacts correctly with the database.
3. Validating the integration contract
When we mock the persistence layer, we are not ignoring the database. Instead, we are verifying the “contract” of communication between our use case and the persistence layer. The test should ensure the use case calls the persistence layer method with the correct Entity object—one that was built with validated data and our business logic applied (like generating the unique ID).
Where Mockito Argument Capture shines
In the previous section, we talked about data validation; this is exactly where Argument Capture shines. Argument Capture allows you to inspect the exact object passed to the mock, ensuring data integrity and consistency before it’s ever persisted.
A little brief about mockito
If you’ve read this far, you probably already know about Mockito. But if you’re a beginner in the Java ecosystem, I should first tell you:
Mockito is a popular open-source Java framework used for unit testing. It allows developers to create mock objects that mimic the behavior of external dependencies, simplifying the testing process by isolating the code under test. Essentially, it helps you simulate how your code interacts with other parts of your application during testing, without needing those other parts to be fully functional or even present.
Let’s code
So, we’re going to use the small project I mentioned earlier that handles characters. If you want to use this project for practice,
feel free to clone and enjoy it (github link).
Let’s start with a simple example, we have the CreateCharacter
use case, as mentioned before, the persistence layer (repository) is already
tested.
Take a look at the use case class:
@ApplicationScoped // A CDI bean managed by Quarkus
public class CreateCharacter {
CreateCharacterRepository createCharacterRepository; // Repository for data persistence, injected via constructor
GetCharacter getCharacter;
CreateCharacter(
CreateCharacterRepository createCharacterRepository,
GetCharacter getCharacter
) {
this.createCharacterRepository = createCharacterRepository;
this.getCharacter = getCharacter;
}
@Transactional
public CharacterDTO createCharacter(CharacterDTO characterDTO) { // The main method of our use case, to be tested
validateCharacter(characterDTO);
return dtoToPersistence(characterDTO);
}
private CharacterDTO dtoToPersistence(CharacterDTO characterDTO) {
CharacterEntity characterEntity = new CharacterEntity();
characterEntity.setId(UUID.randomUUID().toString());
characterEntity.setName(characterDTO.name());
characterEntity.setAlignment(characterDTO.alignment());
characterEntity.setSuperGroup(characterDTO.superGroup());
createCharacterRepository.persistCharacter(characterEntity);
Log.infof("Character created with id: %s", characterEntity.getId());
return getCharacter.getCharacter(characterEntity.getId());
}
private void validateCharacter(CharacterDTO characterDTO) { // Method to validate the business rules for character creation
Objects.requireNonNull(characterDTO);
if (characterDTO.name().isBlank() || characterDTO.alignment().isBlank()) {
throw new IllegalArgumentException("Name and alignment cannot be empty");
}
if (getCharacter.exists(characterDTO.name())) {
throw new BadRequestException("Character with name " + characterDTO.name() + " already exists");
}
}
}
Some details about the created object
If you look at the code, you can see we have a simple entity named CharacterEntity
with only four parameters: an ID generated by us,
a name, an alignment, and a supergroup.
In the validateCharacter
method, we enforce the constraints for our business rules.
We check if the request is complete and if any character with the same name already exists in our database.
Let’s Code: Demonstrating Argument Capture
Now, let’s dive into the code and see how we can apply Argument Capture. We will start with a simple and less effective test.
Then progressively improve it, highlighting the key differences and benefits.
This will show exactly why a simple verify
might not be enough.
We’ll be testing our CreateCharacter use case, ensuring it correctly handles the creation logic before calling the persistence layer. (See on the class test the setup method annotated with BeforeEach).
Case 1: The ‘Naive’ Test (Just Calling the Method)
This first test, while a start, is the most basic and leaves a lot to be desired. It simply checks if the persistCharacter
method
was called on the mocked repository, but it doesn’t verify what was passed to it.
@Test
@DisplayName("The 'Naive' Test: Just checks if the persist method was called")
void createCharacterShouldCallPersistMethod() {
// Mock the dependencies to simulate their behavior
when(getCharacter.exists(anyString())).thenReturn(false);
when(getCharacter.getCharacter(anyString())).thenReturn(characterDTO);
// Execute the method under test
createCharacter.createCharacter(characterDTO);
// The test will pass if the persistence layer's method is called once.
// However, we don't know which object was passed, nor if its data is correct!
verify(createCharacterRepository, times(1)).persistCharacter(any(CharacterEntity.class));
}
This test has a significant flaw: it provides a false sense of security. It tells us that the use case is calling the correct method,
but it doesn’t confirm if the CharacterEntity
object being passed has the correct data, such as a generated ID or the validated input values.
But believe me, it’s very common to see this kind of unit tests on many projects, especially when the company don’t have a code review policy and
some developers or don’t know how to test properly or don’t care about it.
Case 2: A ‘Better’ Test (But Still Incomplete)
This second test is an improvement. It verifies that the CreateCharacter
use case returns the expected CharacterDTO
, which is a good practice.
Most of the developers who care about tests, make the tests on this level. Its good test, but missing something important.
This confirms that the returned data is correct, but it still fails to validate the crucial part of the contract: the data being sent to the mocked repository.
Take a look.
@Test
@DisplayName("The 'Better' Test: Verifying the returned DTO, but not the persisted object")
void createCharacterShouldWorkAndReturnCorrectDTO() {
when(getCharacter.exists(anyString())).thenReturn(false);
when(getCharacter.getCharacter(anyString())).thenReturn(characterDTO);
CharacterDTO returnedDto = createCharacter.createCharacter(characterDTO);
// This is good! We assert that the returned object has the correct data.
// But what about the object sent to the repository?
assertEquals(returnedDto.name(), characterDTO.name());
assertEquals(returnedDto.alignment(), characterDTO.alignment());
assertEquals(returnedDto.superGroup(), characterDTO.superGroup());
}
This test is better because it validates the output of the use case. However, it still doesn’t explicitly verify the “contract” with the
persistence layer.
What if the use case builds the CharacterEntity
incorrectly? The test would still pass as long as the mocked getCharacter
returns
the correct CharacterDTO
. We’re not fully testing the business logic of our use case itself.
Case 3: The Complete Test (Leveraging Argument Capture)
This is where Argument Capture truly shines. It allows us to go beyond simply verifying a method call. We can “capture” the exact
CharacterEntity
object that was passed to the createCharacterRepository.persistCharacter
method and perform detailed assertions on it.
This ensures that the use case is not only calling the right method but also providing the correct, validated, and complete data.
@Test
@DisplayName("The Complete Test: Using Argument Capture to ensure data consistency")
void createCharacterShouldWorkAndPersistWithCorrectData() {
when(getCharacter.exists(anyString())).thenReturn(false);
when(getCharacter.getCharacter(anyString())).thenReturn(characterDTO);
CharacterDTO returnedDto = createCharacter.createCharacter(characterDTO);
// First, we can still assert the returned DTO for a complete test.
assertEquals(returnedDto.name(), characterDTO.name());
assertEquals(returnedDto.alignment(), characterDTO.alignment());
assertEquals(returnedDto.superGroup(), characterDTO.superGroup());
// Using Argument Captor to get the object that was passed to the persist method.
ArgumentCaptor<CharacterEntity> characterCaptor = ArgumentCaptor.forClass(CharacterEntity.class);
verify(createCharacterRepository, times(1)).persistCharacter(characterCaptor.capture());
// Get the captured Entity and perform detailed assertions on its state.
CharacterEntity capturedCharacter = characterCaptor.getValue();
// Now we can assert the critical business logic.
assertNotNull(capturedCharacter.getId(), "The ID should be generated by the use case.");
assertEquals("Goku", capturedCharacter.getName(), "The name should match the DTO.");
assertEquals("Lawful Good", capturedCharacter.getAlignment(), "The alignment should match the DTO.");
assertEquals("Z Fighters", capturedCharacter.getSuperGroup(), "The supergroup should match the DTO.");
}
This final test is the gold standard. It guarantees that our use case’s business logic is working correctly by asserting
not just what is returned, but also what is being passed to external dependencies. It confirms that the id
was generated and
that the data from the DTO was correctly mapped to the CharacterEntity
.
Conclusion
The example presented was intentionally simple to make it easy to read and understand. However, it’s clear how in more complex scenarios—such as queue processing, microservice orchestration, or more robust service layers—the use of Argument Capture becomes essential.
It allows us not only to verify if a method was called but also to inspect the exact state of the object being passed to a dependency. This guarantees the integrity and consistency of the data flowing between different application layers, even before it’s persisted or sent to an external service.
With Argument Capture, you get an extra layer of assurance that your business logic, minimizing the chance of data bugs and failures in production. It’s a powerful tool for elevating the quality of tests and the reliability of your code. Adopting it is a fundamental step toward a more robust, agile, and surprise-free development cycle.
Feel free for comment and give any suggestion, I hope the article can help you to write robust and reliable unit tests. See you in the next articles.