Asserting Kafka events in a Spring Boot application with ease.
Working with an distributed event-driven system is difficult. Writing clean and easy-to-read integration tests is even more challenging.
This library provides tools to await events and verify their content. When assertions don't hold it provides detailed error messages and provides additional information about other events, which nearly match the assertion.
Add dependency to pom.xml:
<dependency>
<groupId>it.obenland.lib</groupId>
<artifactId>event-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
Configure a Kafka Interceptor to application.properties:
spring.kafka.consumer.properties.interceptor.classes=it.obenland.lib.eventtest.EventInterceptor
spring.kafka.producer.properties.interceptor.classes=it.obenland.lib.eventtest.EventInterceptor
The tests have to be configured to clear previous received events, and it's
encouraged to validate the correct usage of assertions after each test. This
checks for assertions which are set up, but never executed, because the final
call to isProduced, isConsumed or isCommitted is missing.
@BeforeEach
void setUp() {
EventAsserter.reset();
}
@AfterEach
void tearDown() {
EventAsserter.validate();
}You can use static imports to improve the readability of your tests:
import static it.obenland.lib.eventtest.EventAsserter.*;
import static it.obenland.lib.eventtest.EventPayload.*;// Send an event to Kafka, e.g. inside your production code
kafkaTemplate.send(record);
// Wait for an event
awaitEvent().withTopic("test.topic")
.withKey("test.key")
.withContentType("test.contentType")
.isProduced();Let's assume our application sends an event to Kafka and we want to verify it. We start by awaiting events:
awaitEvent()
[...]This creates a context where we can express our assertions. For example we want to verify the topic where a event was sent to, the key and content type of the event. We can chain different requirements, which will be checked in given order:
awaitEvent()
.withTopic("test.topic")
.withKey("test.key")
.withContentType("test.contentType")
[...]But this doesn't do anything. We have to tell the assertion where to look for the event. There are three stages of the rout of an event:
- isProduced
The event was sent by the producer. This is useful to check if our application creates the correct events. - isConsumed
The event listener of our application processed the event - isCommitted
The event listener commited the offset of a consumed event
Let's say we want to check our application to create the correct events:
awaitEvent()
.withTopic("test.topic")
.withKey("test.key")
.withContentType("test.contentType")
.isProduced();Checks for the topic
withTopic(String topic)Checks for the key:
withKey(String key)Checks for a header 'Content-Type' with given value
withContentType(String contentType)Checks for a header 'Content-Id' with given value
withContentId(String contentId)Checks for a specific header. You can check for header name or header name with value
withHeader(...)Checks for the number of events which passes previously provided requirements
times(int times)Requires there is only one event which matches
one()Requires no event matches given requirements
none()Checks for the eventPayload/content of the event.
withPayload(Payload eventPayload)You can check for the eventPayload of an event by providing a template and enrich it by replacing placeholders.
Placeholders begin with ${, followed by
an identifier and end with }. You can use them anywhere.
awaitEvent()
.withPayload(fromJson(
"""
{
"id": "${id}",
"value": "${value}"
}
""")
.withId("123")
.wtih("value","foo bar"))
.isProduced();There are two predefined placeholders with their own method:
withId(String id); // for ${id}
withTimestamp(String timestamp); // for ${timestamp}
withTimestamp(Instant timestamp); // for ${timestamp}But you can choose any placeholder as you like, and provide it with:
with(String key, String value);And you can add your own specialized methods using Lombok's @ExtensionMethod.
Arrays have got special treatment. Insted of providing payload templates with different sized arrays, you can provide an array with one entry and configure the array inside your test:
var entries = List.of(
// some records with id and name fields.
);
awaitEvent()
.withPayload(fromJson(
"""
{
"id": "${id}"
"entries": [
{
"id": "${id}",
"name": "${name}"
}
]
}
""")
.withId(123)
.withArray(
"/entries",
entries,
(payload, entry) -> {
payload.withId(entry.id())
.with("name", entry.name())
}))
.isProduced();Configuring arrays create a different context, so there are no naming collisions between placeholders outside and inside of the array.