🔨 Don’t abuse any*()
Did I mean any(body) or any(one) or any(thing)?
The answer might be disappointing for some but interesting to engineers. I meant the ArgumentMatchers that we use in Java unit tests.
What the heck is an ArgumentMatcher anyway?
An ArgumentMatcher is something that does a matching of an argument passed to a method. ArgumentMatchers are used in unit tests so that certain behaviors can be expressed (mocking) when a specific type of argument is passed.
Nothing fancy about the term or the usage.
Talk is cheap, show me the code
Let us see an example:
Mockito.when(awsSnsAccessor.publishWithName(any())).thenReturn(new PublishResult());
When the awsSnsAccessor.publishWithName() method is called with any argument, the return should be a new object of PublishResult.
This looks benign and a good mocking especially when you read it in the entirety of the test case:
final AwsSnsAccessor awsSnsAccessor = Mockito.mock(AwsSnsAccessor.class);
final SourceDeleteDataEvent sourceDeleteDataEvent = new SourceDeleteDataEvent(awsSnsAccessor);
Mockito.when(awsSnsAccessor.publishWithName(any())).thenReturn(new PublishResult());
sourceDeleteDataEvent.publish(data);
Mockito.verify(awsSnsAccessor, Mockito.times(1)).publishWithName(any());
We want to test the SourceDeleteDataEvent#publish method and want to make sure a message is published once using via awsSnsAccessor object. The verify statements make sure that the accessor was called once and when mocking ensure that an object is returned when publishWithName() is called so that we don’t see NullPointerException.
Where is the problem?
Context: When a customer is migrated from region A to region B. We need to delete the customer data in the source i.e. in region A.
Let us see the implementation of the publish() method first, maybe you will find the issue:
@Override
public void publish(final MigrationData data) {
final MigrationDataDeletionEvent dataEvent = new MigrationDataDeletionEvent(
data.getMigrationId(),
data.getCustomerId(),
data.getSourceRealm(), // ----->>>>>>> deletion realm
data.getReportingPlatformOnMigration(),
data.getDeletionTriggerTime()
);
DATA_DELETION_TOPICS.forEach(topic ->
publish(data, dataEvent, topic)
);
}
private void publish(MigrationData data, MigrationDataDeletionEvent dataEvent, String topic) {
..
..
}
If you have not guessed this yet then here is the big reveal.
What would happen if I set the deletionRealm incorrectly?
data.getTargetRealm()
You guessed right! A Sev 0 because you deleted the customer data in the region where they are active.
It is a little far-fetched because generally you would have an integration test in place that is run before deployment to production or you may have run manual tests to confirm everything is working as expected.
The bit about manual intervention is dangerous in its way but let us not go into that discussion and focus on the issue at hand.
Repeat after me
→ This issue could have been caught in the unit test if it were exhaustive.
→ And how could this have been exhaustive?
→ By not using any() matcher.
Changing the test to check concrete objects:
final PublishWithNameRequest request = new PublishWithNameRequest()
.withTopicName(topic).withMessage(createRequestFromObject(data));
Mockito.when(awsSnsAccessor.publishWithName(request)).thenReturn(new PublishResult());
Mockito.verify(awsSnsAccessor, Mockito.times(1)).publishWithName(request);
Notice how we are matching a concrete object, if the object does not match due to a mismatch in one of its fields, it will fail in the verify step.
It doesn’t work that way (always)
Indeed, at times creating an expected object is a big task in itself and sometimes impossible to do.
For example, imagine that we set the deletion time to the current system time.
public void publish(final MigrationData data) {
final MigrationDataDeletionEvent dataEvent = new MigrationDataDeletionEvent(
data.getMigrationId(),
data.getCustomerId(),
data.getSourceRealm(),
data.getReportingPlatformOnMigration(),
Instant.EPOCH.toEpochMilli() // ----->>>>>>> current time
);
DATA_DELETION_TOPICS.forEach(topic ->
publish(data, dataEvent, topic)
);
}
Now, how would you create a concrete object? The answer is either — (1) You modify the SourceDeleteDataEvent class to accept a child of the Instant class and use it to get the EOPC and then mock it in your test; or (2) you can use ArgumentCaptors.
What the heck is an ArgumentCaptor?
An ArgumentCaptor is something that allows us to capture the arguments passed to a method that can be inspected later.
ArgumentCaptor is perfect for this use case because we cannot build a concrete object which has the right timestamp but we need to verify the other fields of the object anyway.
final ArgumentCaptor<PublishWithNameRequest> argumentCaptor =
ArgumentCaptor.forClass(PublishWithNameRequest.class);
final AwsSnsAccessor awsSnsAccessor = Mockito.mock(AwsSnsAccessor.class);
final SourceDeleteDataEvent sourceDeleteDataEvent = new SourceDeleteDataEvent(awsSnsAccessor);
Mockito.when(awsSnsAccessor.publishWithName(any())).thenReturn(new PublishResult());
sourceDeleteDataEvent.publish(data);
Mockito.verify(awsSnsAccessor, Mockito.times(1)).publishWithName(argumentCaptor.capture());
assertEquals(EXPECTED_PAYLOAD_MESSAGE_LOOKER, argumentCaptor.get(0).getMessage());
See how we are back to mocking the behavior using any() matcher.
You must have noticed that we created an argumentCaptor and then used it in the verify method to capture the arguments passed to the publishWithName() method.
Now it is easier to verify specific fields of the PublishWithNameRequest object which is done subsequently using the assertEquals method.
The combination of any() with ArgumentCaptor is a formidable technique that enables you to write exhaustive tests and gives you control to verify individual fields of an object.
Conclusion
It is going to be an overhead if you start enforcing this as a requirement for every test that you write. You may not need to test all the concrete objects in all the tests.
Imagine you want to test if sns publish is skipped when a flag is set to true. In this case, you don’t care about the processing done in the method, all you care about is that the publish step is skipped when a flag is set. Assuming that other tests are already present that cover the concrete value matching, this is one of the situations where you can simply use any(). However, it would not harm to enhance it by using the class type.
Mockito.when(awsSnsAccessor.publishWithName(PublishWithNameRequest.class)).thenReturn(new PublishResult());