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
This commit is contained in:
@@ -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.
|
||||
@@ -37,6 +37,7 @@ import org.springframework.util.Assert;
|
||||
*
|
||||
* @author Sam Brannen
|
||||
* @author Oliver Drotbohm
|
||||
* @author Simon Baslé
|
||||
* @since 5.3.3
|
||||
* @see ApplicationEvents
|
||||
* @see RecordApplicationEvents
|
||||
@@ -44,7 +45,7 @@ import org.springframework.util.Assert;
|
||||
*/
|
||||
public abstract class ApplicationEventsHolder {
|
||||
|
||||
private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new ThreadLocal<>();
|
||||
private static final ThreadLocal<DefaultApplicationEvents> applicationEvents = new InheritableThreadLocal<>();
|
||||
|
||||
|
||||
private ApplicationEventsHolder() {
|
||||
|
||||
@@ -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;
|
||||
@@ -32,7 +32,7 @@ import org.springframework.context.PayloadApplicationEvent;
|
||||
*/
|
||||
class DefaultApplicationEvents implements ApplicationEvents {
|
||||
|
||||
private final List<ApplicationEvent> events = new ArrayList<>();
|
||||
private final List<ApplicationEvent> events = new CopyOnWriteArrayList<>();
|
||||
|
||||
|
||||
void addEvent(ApplicationEvent event) {
|
||||
|
||||
@@ -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.
|
||||
@@ -29,6 +29,7 @@ import org.junit.jupiter.api.AfterAll;
|
||||
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;
|
||||
@@ -41,6 +42,8 @@ import org.junit.jupiter.api.extension.ExtensionContext.Store;
|
||||
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;
|
||||
@@ -51,8 +54,10 @@ import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
|
||||
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;
|
||||
@@ -68,6 +73,7 @@ import org.springframework.util.ReflectionUtils.MethodFilter;
|
||||
* {@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
|
||||
@@ -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 =
|
||||
@@ -135,9 +148,51 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
|
||||
@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}.
|
||||
|
||||
Reference in New Issue
Block a user