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:
Simon Baslé
2023-02-23 17:33:41 +01:00
committed by Simon Baslé
parent 906c54faff
commit b39e93d0d1
8 changed files with 339 additions and 14 deletions

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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}.