Skip to content

Commit

Permalink
Add test support to record async events, with Junit5 caveat
Browse files Browse the repository at this point in the history
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
  • Loading branch information
simonbasle committed May 5, 2023
1 parent 906c54f commit b39e93d
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 14 deletions.
1 change: 1 addition & 0 deletions spring-test/spring-test.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dependencies {
}
testImplementation("io.projectreactor.netty:reactor-netty-http")
testImplementation("de.bechte.junit:junit-hierarchicalcontextrunner")
testImplementation("org.awaitility:awaitility")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine") {
exclude group: "junit", module: "junit"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -37,14 +37,15 @@
*
* @author Sam Brannen
* @author Oliver Drotbohm
* @author Simon Baslé
* @since 5.3.3
* @see ApplicationEvents
* @see RecordApplicationEvents
* @see ApplicationEventsTestExecutionListener
*/
public abstract class ApplicationEventsHolder {

private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new ThreadLocal<>();
private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new InheritableThreadLocal<>();


private ApplicationEventsHolder() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

package org.springframework.test.context.event;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Stream;

import org.springframework.context.ApplicationEvent;
Expand All @@ -32,7 +32,7 @@
*/
class DefaultApplicationEvents implements ApplicationEvents {

private final List<ApplicationEvent> events = new ArrayList<>();
private final List<ApplicationEvent> events = new CopyOnWriteArrayList<>();


void addEvent(ApplicationEvent event) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -29,6 +29,7 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
Expand All @@ -41,6 +42,8 @@
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.platform.commons.annotation.Testable;

import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -51,8 +54,10 @@
import org.springframework.core.annotation.RepeatableContainers;
import org.springframework.lang.Nullable;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.test.context.TestContextManager;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.support.PropertyProvider;
import org.springframework.test.context.support.TestConstructorUtils;
import org.springframework.util.Assert;
Expand All @@ -68,6 +73,7 @@
* {@code @SpringJUnitWebConfig}.
*
* @author Sam Brannen
* @author Simon Baslé
* @since 5.0
* @see org.springframework.test.context.junit.jupiter.EnabledIf
* @see org.springframework.test.context.junit.jupiter.DisabledIf
Expand All @@ -94,6 +100,13 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes

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

/**
* {@link Namespace} in which {@code @RecordApplicationEvents} validation error messages
* are stored, keyed by test class.
*/
private static final Namespace RECORD_APPLICATION_EVENTS_VALIDATION_NAMESPACE =
Namespace.create(SpringExtension.class.getName() + "#recordApplicationEvents.validation");

// Note that @Test, @TestFactory, @TestTemplate, @RepeatedTest, and @ParameterizedTest
// are all meta-annotated with @Testable.
private static final List<Class<? extends Annotation>> JUPITER_ANNOTATION_TYPES =
Expand Down Expand Up @@ -135,9 +148,51 @@ public void afterAll(ExtensionContext context) throws Exception {
@Override
public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
validateAutowiredConfig(context);
validateRecordApplicationEventsConfig(context);
getTestContextManager(context).prepareTestInstance(testInstance);
}

/**
* Validate that test class or its enclosing class doesn't attempt to record
* application events in a parallel mode that makes it un-deterministic
* ({@code @TestInstance(PER_CLASS)} and {@code @Execution(CONCURRENT)}
* combination).
* @since 6.1.0
*/
private void validateRecordApplicationEventsConfig(ExtensionContext context) {
// We save the result in the ExtensionContext.Store so that we don't
// re-validate all methods for the same test class multiple times.
Store store = context.getStore(RECORD_APPLICATION_EVENTS_VALIDATION_NAMESPACE);

String errorMessage = store.getOrComputeIfAbsent(context.getRequiredTestClass(), testClass -> {
boolean record = TestContextAnnotationUtils.hasAnnotation(testClass, RecordApplicationEvents.class);
if (!record) {
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
}
final TestInstance testInstance = TestContextAnnotationUtils.findMergedAnnotation(testClass, TestInstance.class);

if (testInstance == null || testInstance.value() != TestInstance.Lifecycle.PER_CLASS) {
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
}

final Execution execution = TestContextAnnotationUtils.findMergedAnnotation(testClass, Execution.class);

if (execution == null || execution.value() != ExecutionMode.CONCURRENT) {
return NO_AUTOWIRED_VIOLATIONS_DETECTED;
}

return "Test classes or inner classes that @RecordApplicationEvents must not be run in parallel "
+ "with the @TestInstance(Lifecycle.PER_CLASS) configuration. Use either @Execution(SAME_THREAD), "
+ "@TestInstance(PER_METHOD) or disable parallel execution altogether. Note that when recording "
+ "events in parallel, one might see events published by other tests as the application context "
+ "can be common.";
}, String.class);

if (errorMessage != NO_AUTOWIRED_VIOLATIONS_DETECTED) {
throw new IllegalStateException(errorMessage);
}
}

/**
* Validate that test methods and test lifecycle methods in the supplied
* test class are not annotated with {@link Autowired @Autowired}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

import java.util.stream.Stream;

import org.assertj.core.api.InstanceOfAssertFactories;
import org.awaitility.Awaitility;
import org.awaitility.Durations;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
Expand Down Expand Up @@ -237,6 +240,38 @@ void afterEach(@Autowired ApplicationEvents events, TestInfo testInfo) {
}
}

@Nested
@TestInstance(PER_CLASS)
class AsyncEventTests {

@Autowired
ApplicationEvents applicationEvents;

@Test
void asyncPublication() throws InterruptedException {
Thread t = new Thread(() -> context.publishEvent(new CustomEvent("async")));
t.start();
t.join();

assertThat(this.applicationEvents.stream(CustomEvent.class))
.singleElement()
.extracting(CustomEvent::getMessage, InstanceOfAssertFactories.STRING)
.isEqualTo("async");
}

@Test
void asyncConsumption() {
context.publishEvent(new CustomEvent("sync"));

Awaitility.await().atMost(Durations.ONE_SECOND)
.untilAsserted(() -> assertThat(assertThat(this.applicationEvents.stream(CustomEvent.class))
.singleElement()
.extracting(CustomEvent::getMessage, InstanceOfAssertFactories.STRING)
.isEqualTo("sync")));
}

}


private static void assertEventTypes(ApplicationEvents applicationEvents, String... types) {
assertThat(applicationEvents.stream().map(event -> event.getClass().getSimpleName()))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,25 +18,35 @@

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.assertj.core.api.InstanceOfAssertFactories;
import org.awaitility.Awaitility;
import org.awaitility.Durations;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.testkit.engine.EngineExecutionResults;
import org.junit.platform.testkit.engine.EngineTestKit;
import org.junit.platform.testkit.engine.Events;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.ApplicationEventsHolder;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.event.TestContextEvent;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -47,23 +57,52 @@
* in conjunction with JUnit Jupiter.
*
* @author Sam Brannen
* @author Simon Baslé
* @since 5.3.3
*/
class ParallelApplicationEventsIntegrationTests {

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

@Test
void rejectTestsInParallelWithInstancePerClassAndRecordApplicationEvents() {
Class<?> testClass = TestInstancePerClassTestCase.class;

@ParameterizedTest
@ValueSource(classes = {TestInstancePerMethodTestCase.class, TestInstancePerClassTestCase.class})
void executeTestsInParallel(Class<?> testClass) {
EngineTestKit.engine("junit-jupiter")//
final EngineExecutionResults results = EngineTestKit.engine("junit-jupiter")//
.selectors(selectClass(testClass))//
.configurationParameter("junit.jupiter.execution.parallel.enabled", "true")//
.configurationParameter("junit.jupiter.execution.parallel.config.dynamic.factor", "10")//
.execute();

//extract the messages from failed TextExecutionResults
assertThat(results.containerEvents().failed()//
.stream().map(e -> e.getRequiredPayload(TestExecutionResult.class)//
.getThrowable().get().getMessage()))//
.singleElement(InstanceOfAssertFactories.STRING)
.isEqualToIgnoringNewLines("""
Test classes or inner classes that @RecordApplicationEvents\s
must not be run in parallel with the @TestInstance(Lifecycle.PER_CLASS) configuration.\s
Use either @Execution(SAME_THREAD), @TestInstance(PER_METHOD) or disable parallel\s
execution altogether. Note that when recording events in parallel, one might see events\s
published by other tests as the application context can be common.
""");
}

@Test
void executeTestsInParallelInstancePerMethod() {
Class<?> testClass = TestInstancePerMethodTestCase.class;
Events testEvents = EngineTestKit.engine("junit-jupiter")//
.selectors(selectClass(testClass))//
.configurationParameter("junit.jupiter.execution.parallel.enabled", "true")//
.configurationParameter("junit.jupiter.execution.parallel.config.dynamic.factor", "10")//
.execute()//
.testEvents()//
.assertStatistics(stats -> stats.started(10).succeeded(10).failed(0));
.testEvents();
//list failed events in case of test errors to get a sense of which tests failed
Events failedTests = testEvents.failed();
if (failedTests.count() > 0) {
failedTests.debug();
}
testEvents.assertStatistics(stats -> stats.started(13).succeeded(13).failed(0));

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

@Test
void compareToApplicationEventsHolder(ApplicationEvents applicationEvents) {
ApplicationEvents fromThreadHolder = ApplicationEventsHolder.getRequiredApplicationEvents();
assertThat(fromThreadHolder.stream())
.hasSameElementsAs(this.events.stream().toList())
.hasSameElementsAs(applicationEvents.stream().toList());
}

@Test
void asyncPublication(ApplicationEvents events) throws InterruptedException {
final ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.execute(() -> this.context.publishEvent("asyncPublication"));
executorService.shutdown();
executorService.awaitTermination(10, TimeUnit.SECONDS);

assertThat(events.stream().filter(e -> !(e instanceof TestContextEvent))
.map(e -> (e instanceof PayloadApplicationEvent<?> pae ? pae.getPayload().toString() : e.toString())))
.containsExactly("asyncPublication");
}

@Test
void asyncConsumption() {
this.context.publishEvent("asyncConsumption");

Awaitility.await().atMost(Durations.ONE_SECOND).untilAsserted(() ->//
assertThat(ApplicationEventsHolder//
.getRequiredApplicationEvents()//
.stream()//
.filter(e -> !(e instanceof TestContextEvent))//
.map(e -> (e instanceof PayloadApplicationEvent<?> pae ? pae.getPayload().toString() : e.toString()))//
).containsExactly("asyncConsumption"));
}

private void assertTestExpectations(ApplicationEvents events, TestInfo testInfo) {
String testName = testInfo.getTestMethod().get().getName();
String threadName = Thread.currentThread().getName();
Expand Down
Loading

0 comments on commit b39e93d

Please sign in to comment.