One of the core features of Spring is the event publishing functionality. We can use events to decouple parts of our application and implement a publish-subscribe pattern. One part of our application can publish an event that multiple listeners (even asynchronously) react to. As part of Spring Framework 5.3.3 (Spring Boot 2.4.2), we can now record and verify all published events (ApplicationEvent
) when testing Spring Boot applications using @RecrodApplicationEvents
.
Setup To Record an ApplicationEvent with Spring Boot
To use this feature, we only need the Spring Boot Starter Test that is part of every Spring Boot project you bootstrap at start.spring.io.
1 2 3 4 5 | <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> |
Make sure to use a Spring Boot version >= 2.4.2 as we need a Spring Framework version >= 5.3.3.
There is one additional requirement for our tests: we need to work with a Spring TestContext
as event publishing is a core functionality of the ApplicationContext
.
Hence it doesn't work for a unit test where no Spring TestContext framework support is used. There are multiple Spring Boot test slice annotations that conveniently bootstrap the context for our test.
Introduction To Spring Event Publishing
As an example, we'll test a Java class that emits a UserCreationEvent
whenever we successfully create a new user. The event includes metadata about the user that is relevant for subsequent tasks:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class UserCreationEvent extends ApplicationEvent { private final String username; private final Long id; public UserCreationEvent(Object source, String username, Long id) { super(source); this.username = username; this.id = id; } // getters } |
As of Spring Framework 4.2, we don't have to extend the abstract ApplicationEvent
class and can use any POJO as our event class. Refer to this article for a great introduction to application events with Spring Boot.
Our UserService
creates and stores our new users. We can create either a single user or a batch of users:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Service public class UserService { private final ApplicationEventPublisher eventPublisher; public UserService(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } public Long createUser(String username) { // logic to create a user and store it in a database Long primaryKey = ThreadLocalRandom.current().nextLong(1, 1000); this.eventPublisher.publishEvent(new UserCreationEvent(this, username, primaryKey)); return primaryKey; } public List<Long> createUser(List<String> usernames) { List<Long> resultIds = new ArrayList<>(); for (String username : usernames) { resultIds.add(createUser(username)); } return resultIds; } } |
Once the user is part of our system, we'll notify other components of our application by publishing a UserCreationEvent
.
As an example, our application performs two additional operations whenever we fire such an UserCreationEvent
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @Component public class ReportingListener { @EventListener(UserCreationEvent.class) public void reportUserCreation(UserCreationEvent event) { // e.g. increment a counter to report the total amount of new users System.out.println("Increment counter as new user was created: " + event); } @EventListener(UserCreationEvent.class) public void syncUserToExternalSystem(UserCreationEvent event) { // e.g. send a message to a messaging queue to inform other systems System.out.println("informing other systems about new user: " + event); } } |
Record And Verify ApplicationEvents With Spring Boot
Let's write our first test that ensures the UserService
emits an event whenever we create a new user. We instruct Spring to capture our events using the @RecordApplicationEvents
annotation on top of our test class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @SpringBootTest @RecordApplicationEvents class UserServiceFullContextTest { @Autowired private ApplicationEvents applicationEvents; @Autowired private UserService userService; @Test void userCreationShouldPublishEvent() { this.userService.createUser("duke"); assertEquals(1, applicationEvents .stream(UserCreationEvent.class) .filter(event -> event.getUsername().equals("duke")) .count()); // There are multiple events recorded // PrepareInstanceEvent // BeforeTestMethodEvent // BeforeTestExecutionEvent // UserCreationEvent applicationEvents.stream().forEach(System.out::println); } } |
After we execute the public method of our class under test (createUser
of the UserService
in this example), we can request all captured events from the ApplicationEvents
bean that we inject to our test.
The public .stream()
method of the ApplicationEvents
class allows iterating over all recorded events for a test. There's an overloaded version of .stream()
where we request a stream of only specific events.
Even though we're only emitting one event from our application, Spring captures four events for the test above. The remaining three events are Spring specific like PrepareInstanceEvent
from the TestContext framework.
As we're using the JUnit Jupiter and the SpringExtension
(registered for us when using @SpringBootTest
), we can also inject the ApplicationEvents
bean to a JUnit lifecycle method or directly to a test:
1 2 3 4 5 6 7 | @Test void batchUserCreationShouldPublishEvents(@Autowired ApplicationEvents events) { List<Long> result = this.userService.createUser(List.of("duke", "mike", "alice")); assertEquals(3, result.size()); assertEquals(3, events.stream(UserCreationEvent.class).count()); } |
The instance of ApplicationEvents
is created before and removed after each test as part of the current thread. Hence you can even use field injection and @TestInstance(TestInstance.Lifecycle.PER_CLASS)
to share the test instance between multiple tests (PER_METHOD
is the default).
Please note that it might be overkill to start the whole Spring Context using @SpringBootTest
for such a test. We could also write a test that populates a minimal Spring TestContext
with just our UserService
bean to verify that our UserCreationEvent
is published:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @RecordApplicationEvents @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserService.class) class UserServicePerClassTest { @Autowired private ApplicationEvents applicationEvents; @Autowired private UserService userService; @Test void userCreationShouldPublishEvent() { this.userService.createUser("duke"); assertEquals(1, applicationEvents .stream(UserCreationEvent.class) .filter(event -> event.getUsername().equals("duke")) .count()); applicationEvents.stream().forEach(System.out::println); } } |
… or use an alternative testing approach.
Alternatives to Testing Spring Events
Depending on what you want to achieve with your test, it might be sufficient enough to verify this functionality with a unit test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @ExtendWith(MockitoExtension.class) class UserServiceUnitTest { @Mock private ApplicationEventPublisher applicationEventPublisher; @Captor private ArgumentCaptor<UserCreationEvent> eventArgumentCaptor; @InjectMocks private UserService userService; @Test void userCreationShouldPublishEvent() { Long result = this.userService.createUser("duke"); Mockito.verify(applicationEventPublisher).publishEvent(eventArgumentCaptor.capture()); assertEquals("duke", eventArgumentCaptor.getValue().getUsername()); } @Test void batchUserCreationShouldPublishEvents() { List<Long> result = this.userService.createUser(List.of("duke", "mike", "alice")); Mockito .verify(applicationEventPublisher, Mockito.times(3)) .publishEvent(any(UserCreationEvent.class)); } } |
Note that we're not using any Spring Test support here and relying solely on Mockito and JUnit Jupiter.
Another approach would be to not explicitly verify this technical detail (publishing events) and verify the whole use case with an integration test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class ApplicationIT { @Autowired private TestRestTemplate testRestTemplate; @Test void shouldCreateUserAndPerformReporting() { ResponseEntity<Void> result = this.testRestTemplate .postForEntity("/api/users", "duke", Void.class); assertEquals(201, result.getStatusCodeValue()); assertTrue(result.getHeaders().containsKey("Location"), "Response doesn't contain Location header"); // additional assertion to verify the counter was incremented // additional assertion that a new message is part of the queue } } |
In this case, we would need to verify the outcome of our event listeners and e.g. check that we put a message to a queue or incrementing a counter.
Summary Of Testing Spring Events With Spring Boot
All different approaches boil down to behavior vs. state testing. With this new @RecordApplicationEvents
feature of Spring Test, we might be tempted to do more behavior testing and verify the internals of our implementation. In general, we should focus on state (aka. outcome) testing as this supports hassle-free refactorings.
Imagine the following: We use anApplicationEvent
to decouple parts of our application and ensure that this event is fired during a test. Two weeks later, we decide to remove/rework this decoupling (for whatever reasons). Our use case might still work as expected, but our test now fails because we're making assumptions about the technical implementation by verifying how many events we published.
Keep this in mind and not overspecify your tests with details about the implementation (if you want to refactor in the future :D). Nevertheless, there are for sure test cases where this @RecordApplicationEvents
feature helps a lot.
The source code with all test alternatives for this Spring Event testing with Spring Boot is available on GitHub.
Have fun testing your Spring Events,
Philip