Skip to content

Commit b39e93d

Browse files
committed
Add test support to record async events, with Junit5 caveat
This commit modifies the way the `@RecordApplicationEvents` annotation works in tests, allowing for capture of events from threads other than the main test thread (async events) and for the assertion of captured event from a separate thread (e.g. when using `Awaitility`). This is done by switching the `ApplicationEventsHolder` to use an `InheritedThreadLocal`. There is a mutual exclusion between support of asynchronous events vs support of JUnit5 parallel tests with the `@TestInstance(PER_CLASS)` mode. As a result, we favor the former and now `SpringExtension` will invalidate a test class that is annotated (or meta-annotated, or enclosed-annotated) with `@RecordApplicationEvents` AND `@TestInstance(PER_CLASS)` AND `@Execution(CONCURRENT)`. See gh-29827 Closes gh-30020
1 parent 906c54f commit b39e93d

File tree

8 files changed

+339
-14
lines changed

8 files changed

+339
-14
lines changed

Diff for: spring-test/spring-test.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ dependencies {
7676
}
7777
testImplementation("io.projectreactor.netty:reactor-netty-http")
7878
testImplementation("de.bechte.junit:junit-hierarchicalcontextrunner")
79+
testImplementation("org.awaitility:awaitility")
7980
testRuntimeOnly("org.junit.vintage:junit-vintage-engine") {
8081
exclude group: "junit", module: "junit"
8182
}

Diff for: spring-test/src/main/java/org/springframework/test/context/event/ApplicationEventsHolder.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -37,14 +37,15 @@
3737
*
3838
* @author Sam Brannen
3939
* @author Oliver Drotbohm
40+
* @author Simon Baslé
4041
* @since 5.3.3
4142
* @see ApplicationEvents
4243
* @see RecordApplicationEvents
4344
* @see ApplicationEventsTestExecutionListener
4445
*/
4546
public abstract class ApplicationEventsHolder {
4647

47-
private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new ThreadLocal<>();
48+
private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new InheritableThreadLocal<>();
4849

4950

5051
private ApplicationEventsHolder() {

Diff for: spring-test/src/main/java/org/springframework/test/context/event/DefaultApplicationEvents.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
package org.springframework.test.context.event;
1818

19-
import java.util.ArrayList;
2019
import java.util.List;
20+
import java.util.concurrent.CopyOnWriteArrayList;
2121
import java.util.stream.Stream;
2222

2323
import org.springframework.context.ApplicationEvent;
@@ -32,7 +32,7 @@
3232
*/
3333
class DefaultApplicationEvents implements ApplicationEvents {
3434

35-
private final List<ApplicationEvent> events = new ArrayList<>();
35+
private final List<ApplicationEvent> events = new CopyOnWriteArrayList<>();
3636

3737

3838
void addEvent(ApplicationEvent event) {

Diff for: spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java

+56-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -29,6 +29,7 @@
2929
import org.junit.jupiter.api.AfterEach;
3030
import org.junit.jupiter.api.BeforeAll;
3131
import org.junit.jupiter.api.BeforeEach;
32+
import org.junit.jupiter.api.TestInstance;
3233
import org.junit.jupiter.api.extension.AfterAllCallback;
3334
import org.junit.jupiter.api.extension.AfterEachCallback;
3435
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
@@ -41,6 +42,8 @@
4142
import org.junit.jupiter.api.extension.ParameterContext;
4243
import org.junit.jupiter.api.extension.ParameterResolver;
4344
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
45+
import org.junit.jupiter.api.parallel.Execution;
46+
import org.junit.jupiter.api.parallel.ExecutionMode;
4447
import org.junit.platform.commons.annotation.Testable;
4548

4649
import org.springframework.beans.factory.annotation.Autowired;
@@ -51,8 +54,10 @@
5154
import org.springframework.core.annotation.RepeatableContainers;
5255
import org.springframework.lang.Nullable;
5356
import org.springframework.test.context.TestConstructor;
57+
import org.springframework.test.context.TestContextAnnotationUtils;
5458
import org.springframework.test.context.TestContextManager;
5559
import org.springframework.test.context.event.ApplicationEvents;
60+
import org.springframework.test.context.event.RecordApplicationEvents;
5661
import org.springframework.test.context.support.PropertyProvider;
5762
import org.springframework.test.context.support.TestConstructorUtils;
5863
import org.springframework.util.Assert;
@@ -68,6 +73,7 @@
6873
* {@code @SpringJUnitWebConfig}.
6974
*
7075
* @author Sam Brannen
76+
* @author Simon Baslé
7177
* @since 5.0
7278
* @see org.springframework.test.context.junit.jupiter.EnabledIf
7379
* @see org.springframework.test.context.junit.jupiter.DisabledIf
@@ -94,6 +100,13 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
94100

95101
private static final String NO_AUTOWIRED_VIOLATIONS_DETECTED = "NO AUTOWIRED VIOLATIONS DETECTED";
96102

103+
/**
104+
* {@link Namespace} in which {@code @RecordApplicationEvents} validation error messages
105+
* are stored, keyed by test class.
106+
*/
107+
private static final Namespace RECORD_APPLICATION_EVENTS_VALIDATION_NAMESPACE =
108+
Namespace.create(SpringExtension.class.getName() + "#recordApplicationEvents.validation");
109+
97110
// Note that @Test, @TestFactory, @TestTemplate, @RepeatedTest, and @ParameterizedTest
98111
// are all meta-annotated with @Testable.
99112
private static final List<Class<? extends Annotation>> JUPITER_ANNOTATION_TYPES =
@@ -135,9 +148,51 @@ public void afterAll(ExtensionContext context) throws Exception {
135148
@Override
136149
public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
137150
validateAutowiredConfig(context);
151+
validateRecordApplicationEventsConfig(context);
138152
getTestContextManager(context).prepareTestInstance(testInstance);
139153
}
140154

155+
/**
156+
* Validate that test class or its enclosing class doesn't attempt to record
157+
* application events in a parallel mode that makes it un-deterministic
158+
* ({@code @TestInstance(PER_CLASS)} and {@code @Execution(CONCURRENT)}
159+
* combination).
160+
* @since 6.1.0
161+
*/
162+
private void validateRecordApplicationEventsConfig(ExtensionContext context) {
163+
// We save the result in the ExtensionContext.Store so that we don't
164+
// re-validate all methods for the same test class multiple times.
165+
Store store = context.getStore(RECORD_APPLICATION_EVENTS_VALIDATION_NAMESPACE);
166+
167+
String errorMessage = store.getOrComputeIfAbsent(context.getRequiredTestClass(), testClass -> {
168+
boolean record = TestContextAnnotationUtils.hasAnnotation(testClass, RecordApplicationEvents.class);
169+
if (!record) {
170+
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
171+
}
172+
final TestInstance testInstance = TestContextAnnotationUtils.findMergedAnnotation(testClass, TestInstance.class);
173+
174+
if (testInstance == null || testInstance.value() != TestInstance.Lifecycle.PER_CLASS) {
175+
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
176+
}
177+
178+
final Execution execution = TestContextAnnotationUtils.findMergedAnnotation(testClass, Execution.class);
179+
180+
if (execution == null || execution.value() != ExecutionMode.CONCURRENT) {
181+
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
182+
}
183+
184+
return "Test classes or inner classes that @RecordApplicationEvents must not be run in parallel "
185+
+ "with the @TestInstance(Lifecycle.PER_CLASS) configuration. Use either @Execution(SAME_THREAD), "
186+
+ "@TestInstance(PER_METHOD) or disable parallel execution altogether. Note that when recording "
187+
+ "events in parallel, one might see events published by other tests as the application context "
188+
+ "can be common.";
189+
}, String.class);
190+
191+
if (errorMessage != NO_AUTOWIRED_VIOLATIONS_DETECTED) {
192+
throw new IllegalStateException(errorMessage);
193+
}
194+
}
195+
141196
/**
142197
* Validate that test methods and test lifecycle methods in the supplied
143198
* test class are not annotated with {@link Autowired @Autowired}.

Diff for: spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/JUnitJupiterApplicationEventsIntegrationTests.java

+35
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import java.util.stream.Stream;
2020

21+
import org.assertj.core.api.InstanceOfAssertFactories;
22+
import org.awaitility.Awaitility;
23+
import org.awaitility.Durations;
2124
import org.junit.jupiter.api.AfterEach;
2225
import org.junit.jupiter.api.BeforeEach;
2326
import org.junit.jupiter.api.Nested;
@@ -237,6 +240,38 @@ void afterEach(@Autowired ApplicationEvents events, TestInfo testInfo) {
237240
}
238241
}
239242

243+
@Nested
244+
@TestInstance(PER_CLASS)
245+
class AsyncEventTests {
246+
247+
@Autowired
248+
ApplicationEvents applicationEvents;
249+
250+
@Test
251+
void asyncPublication() throws InterruptedException {
252+
Thread t = new Thread(() -> context.publishEvent(new CustomEvent("async")));
253+
t.start();
254+
t.join();
255+
256+
assertThat(this.applicationEvents.stream(CustomEvent.class))
257+
.singleElement()
258+
.extracting(CustomEvent::getMessage, InstanceOfAssertFactories.STRING)
259+
.isEqualTo("async");
260+
}
261+
262+
@Test
263+
void asyncConsumption() {
264+
context.publishEvent(new CustomEvent("sync"));
265+
266+
Awaitility.await().atMost(Durations.ONE_SECOND)
267+
.untilAsserted(() -> assertThat(assertThat(this.applicationEvents.stream(CustomEvent.class))
268+
.singleElement()
269+
.extracting(CustomEvent::getMessage, InstanceOfAssertFactories.STRING)
270+
.isEqualTo("sync")));
271+
}
272+
273+
}
274+
240275

241276
private static void assertEventTypes(ApplicationEvents applicationEvents, String... types) {
242277
assertThat(applicationEvents.stream().map(event -> event.getClass().getSimpleName()))

Diff for: spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/ParallelApplicationEventsIntegrationTests.java

+81-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,25 +18,35 @@
1818

1919
import java.util.Set;
2020
import java.util.concurrent.ConcurrentHashMap;
21+
import java.util.concurrent.ExecutorService;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.TimeUnit;
2124
import java.util.stream.Collectors;
2225
import java.util.stream.Stream;
2326

27+
import org.assertj.core.api.InstanceOfAssertFactories;
28+
import org.awaitility.Awaitility;
29+
import org.awaitility.Durations;
2430
import org.junit.jupiter.api.AfterEach;
2531
import org.junit.jupiter.api.Test;
2632
import org.junit.jupiter.api.TestInfo;
2733
import org.junit.jupiter.api.TestInstance;
2834
import org.junit.jupiter.api.TestInstance.Lifecycle;
2935
import org.junit.jupiter.api.parallel.Execution;
3036
import org.junit.jupiter.api.parallel.ExecutionMode;
31-
import org.junit.jupiter.params.ParameterizedTest;
32-
import org.junit.jupiter.params.provider.ValueSource;
37+
import org.junit.platform.engine.TestExecutionResult;
38+
import org.junit.platform.testkit.engine.EngineExecutionResults;
3339
import org.junit.platform.testkit.engine.EngineTestKit;
40+
import org.junit.platform.testkit.engine.Events;
3441

3542
import org.springframework.beans.factory.annotation.Autowired;
3643
import org.springframework.context.ApplicationContext;
44+
import org.springframework.context.PayloadApplicationEvent;
3745
import org.springframework.context.annotation.Configuration;
3846
import org.springframework.test.context.event.ApplicationEvents;
47+
import org.springframework.test.context.event.ApplicationEventsHolder;
3948
import org.springframework.test.context.event.RecordApplicationEvents;
49+
import org.springframework.test.context.event.TestContextEvent;
4050
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
4151

4252
import static org.assertj.core.api.Assertions.assertThat;
@@ -47,23 +57,52 @@
4757
* in conjunction with JUnit Jupiter.
4858
*
4959
* @author Sam Brannen
60+
* @author Simon Baslé
5061
* @since 5.3.3
5162
*/
5263
class ParallelApplicationEventsIntegrationTests {
5364

5465
private static final Set<String> payloads = ConcurrentHashMap.newKeySet();
5566

67+
@Test
68+
void rejectTestsInParallelWithInstancePerClassAndRecordApplicationEvents() {
69+
Class<?> testClass = TestInstancePerClassTestCase.class;
5670

57-
@ParameterizedTest
58-
@ValueSource(classes = {TestInstancePerMethodTestCase.class, TestInstancePerClassTestCase.class})
59-
void executeTestsInParallel(Class<?> testClass) {
60-
EngineTestKit.engine("junit-jupiter")//
71+
final EngineExecutionResults results = EngineTestKit.engine("junit-jupiter")//
72+
.selectors(selectClass(testClass))//
73+
.configurationParameter("junit.jupiter.execution.parallel.enabled", "true")//
74+
.configurationParameter("junit.jupiter.execution.parallel.config.dynamic.factor", "10")//
75+
.execute();
76+
77+
//extract the messages from failed TextExecutionResults
78+
assertThat(results.containerEvents().failed()//
79+
.stream().map(e -> e.getRequiredPayload(TestExecutionResult.class)//
80+
.getThrowable().get().getMessage()))//
81+
.singleElement(InstanceOfAssertFactories.STRING)
82+
.isEqualToIgnoringNewLines("""
83+
Test classes or inner classes that @RecordApplicationEvents\s
84+
must not be run in parallel with the @TestInstance(Lifecycle.PER_CLASS) configuration.\s
85+
Use either @Execution(SAME_THREAD), @TestInstance(PER_METHOD) or disable parallel\s
86+
execution altogether. Note that when recording events in parallel, one might see events\s
87+
published by other tests as the application context can be common.
88+
""");
89+
}
90+
91+
@Test
92+
void executeTestsInParallelInstancePerMethod() {
93+
Class<?> testClass = TestInstancePerMethodTestCase.class;
94+
Events testEvents = EngineTestKit.engine("junit-jupiter")//
6195
.selectors(selectClass(testClass))//
6296
.configurationParameter("junit.jupiter.execution.parallel.enabled", "true")//
6397
.configurationParameter("junit.jupiter.execution.parallel.config.dynamic.factor", "10")//
6498
.execute()//
65-
.testEvents()//
66-
.assertStatistics(stats -> stats.started(10).succeeded(10).failed(0));
99+
.testEvents();
100+
//list failed events in case of test errors to get a sense of which tests failed
101+
Events failedTests = testEvents.failed();
102+
if (failedTests.count() > 0) {
103+
failedTests.debug();
104+
}
105+
testEvents.assertStatistics(stats -> stats.started(13).succeeded(13).failed(0));
67106

68107
Set<String> testNames = payloads.stream()//
69108
.map(payload -> payload.substring(0, payload.indexOf("-")))//
@@ -162,6 +201,39 @@ void test10(ApplicationEvents events, TestInfo testInfo) {
162201
assertTestExpectations(events, testInfo);
163202
}
164203

204+
@Test
205+
void compareToApplicationEventsHolder(ApplicationEvents applicationEvents) {
206+
ApplicationEvents fromThreadHolder = ApplicationEventsHolder.getRequiredApplicationEvents();
207+
assertThat(fromThreadHolder.stream())
208+
.hasSameElementsAs(this.events.stream().toList())
209+
.hasSameElementsAs(applicationEvents.stream().toList());
210+
}
211+
212+
@Test
213+
void asyncPublication(ApplicationEvents events) throws InterruptedException {
214+
final ExecutorService executorService = Executors.newSingleThreadExecutor();
215+
executorService.execute(() -> this.context.publishEvent("asyncPublication"));
216+
executorService.shutdown();
217+
executorService.awaitTermination(10, TimeUnit.SECONDS);
218+
219+
assertThat(events.stream().filter(e -> !(e instanceof TestContextEvent))
220+
.map(e -> (e instanceof PayloadApplicationEvent<?> pae ? pae.getPayload().toString() : e.toString())))
221+
.containsExactly("asyncPublication");
222+
}
223+
224+
@Test
225+
void asyncConsumption() {
226+
this.context.publishEvent("asyncConsumption");
227+
228+
Awaitility.await().atMost(Durations.ONE_SECOND).untilAsserted(() ->//
229+
assertThat(ApplicationEventsHolder//
230+
.getRequiredApplicationEvents()//
231+
.stream()//
232+
.filter(e -> !(e instanceof TestContextEvent))//
233+
.map(e -> (e instanceof PayloadApplicationEvent<?> pae ? pae.getPayload().toString() : e.toString()))//
234+
).containsExactly("asyncConsumption"));
235+
}
236+
165237
private void assertTestExpectations(ApplicationEvents events, TestInfo testInfo) {
166238
String testName = testInfo.getTestMethod().get().getName();
167239
String threadName = Thread.currentThread().getName();

0 commit comments

Comments
 (0)