diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc index 3bb86387d3..2c2c749b88 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/tel-config.adoc @@ -12,6 +12,8 @@ by default, exactly in the following order: xref:testing/testcontext-framework/application-events.adoc[`ApplicationEvents`]. * `DependencyInjectionTestExecutionListener`: Provides dependency injection for the test instance. +* `MicrometerObservationRegistryTestExecutionListener`: Provides support for + Micrometer's `ObservationRegistry`. * `DirtiesContextTestExecutionListener`: Handles the `@DirtiesContext` annotation for "`after`" modes. * `TransactionalTestExecutionListener`: Provides transactional test execution with diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index 406a964ecf..0ea9712c02 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -50,6 +50,8 @@ dependencies { optional("io.projectreactor:reactor-test") optional("org.jetbrains.kotlinx:kotlinx-coroutines-core") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + optional('io.micrometer:context-propagation') + optional('io.micrometer:micrometer-observation') testImplementation(project(":spring-core-test")) testImplementation(project(":spring-context-support")) testImplementation(project(":spring-oxm")) @@ -58,7 +60,6 @@ dependencies { testImplementation(testFixtures(project(":spring-core"))) testImplementation(testFixtures(project(":spring-tx"))) testImplementation(testFixtures(project(":spring-web"))) - testImplementation('io.micrometer:context-propagation') testImplementation("jakarta.annotation:jakarta.annotation-api") testImplementation("javax.cache:cache-api") testImplementation("jakarta.ejb:jakarta.ejb-api") diff --git a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java index fc4ea1187a..a90c378d6d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestExecutionListener.java @@ -68,6 +68,8 @@ package org.springframework.test.context; * ApplicationEventsTestExecutionListener} *
This listener updates the {@link ObservationThreadLocalAccessor} with the + * {@code ObservationRegistry} obtained from the test's {@link ApplicationContext}, + * if present. + * + * @author Marcin Grzejszczak + * @author Sam Brannen + * @since 6.0.10 + */ +class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExecutionListener { + + private static final Log logger = LogFactory.getLog(MicrometerObservationRegistryTestExecutionListener.class); + + private static final String OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME = + "io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor"; + + /** + * Attribute name for a {@link TestContext} attribute which contains the + * {@link ObservationRegistry} that was previously stored in the + * {@link ObservationThreadLocalAccessor}. + *
After each test method, the previously stored {@code ObservationRegistry}
+ * will be restored. If tests run concurrently this might cause issues unless
+ * the {@code ObservationRegistry} is always the same (which should typically
+ * be the case).
+ */
+ private static final String PREVIOUS_OBSERVATION_REGISTRY = Conventions.getQualifiedAttributeName(
+ MicrometerObservationRegistryTestExecutionListener.class, "previousObservationRegistry");
+
+
+ static {
+ // Trigger eager resolution of Micrometer Observation types during static
+ // initialization of this class to ensure that this listener can be properly
+ // skipped when SpringFactoriesLoader attempts to load it, if micrometer-observation
+ // is not in the classpath or if the version of ObservationThreadLocalAccessor
+ // present does not include the getObservationRegistry() method.
+ String errorMessage =
+ "MicrometerObservationRegistryTestExecutionListener requires micrometer-observation 1.10.8 or higher";
+ Class> clazz;
+ try {
+ clazz = Class.forName(OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME, true,
+ TestExecutionListener.class.getClassLoader());
+ }
+ catch (Throwable ex) {
+ throw new IllegalStateException(errorMessage, ex);
+ }
+
+ Method method = ReflectionUtils.findMethod(clazz, "getObservationRegistry");
+ Assert.state(method != null, errorMessage);
+ }
+
+
+ /**
+ * Returns {@code 2500}.
+ */
+ @Override
+ public final int getOrder() {
+ return 2500;
+ }
+
+ /**
+ * If the test's {@link ApplicationContext} contains an {@link ObservationRegistry}
+ * bean, this method retrieves the {@code ObservationRegistry} currently stored
+ * in {@link ObservationThreadLocalAccessor}, saves a reference to the original
+ * registry as a {@link TestContext} attribute (to be restored in
+ * {@link #afterTestMethod(TestContext)}), and sets the registry from the test's
+ * {@code ApplicationContext} in {@link ObservationThreadLocalAccessor}.
+ * @param testContext the test context for the test; never {@code null}
+ * @see #afterTestMethod(TestContext)
+ */
+ @Override
+ public void beforeTestMethod(TestContext testContext) {
+ testContext.getApplicationContext().getBeanProvider(ObservationRegistry.class)
+ .ifAvailable(registry -> {
+ if (logger.isDebugEnabled()) {
+ logger.debug("""
+ Registering ObservationRegistry from ApplicationContext in \
+ ObservationThreadLocalAccessor for test class \
+ """ + testContext.getTestClass().getName());
+ }
+ ObservationThreadLocalAccessor accessor = ObservationThreadLocalAccessor.getInstance();
+ testContext.setAttribute(PREVIOUS_OBSERVATION_REGISTRY, accessor.getObservationRegistry());
+ accessor.setObservationRegistry(registry);
+ });
+ }
+
+ /**
+ * Retrieves the original {@link ObservationRegistry} that was saved in
+ * {@link #beforeTestMethod(TestContext)} and sets it in
+ * {@link ObservationThreadLocalAccessor}.
+ * @param testContext the test context for the test; never {@code null}
+ * @see #beforeTestMethod(TestContext)
+ */
+ @Override
+ public void afterTestMethod(TestContext testContext) {
+ ObservationRegistry previousObservationRegistry =
+ (ObservationRegistry) testContext.removeAttribute(PREVIOUS_OBSERVATION_REGISTRY);
+ if (previousObservationRegistry != null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("Restoring ObservationRegistry in ObservationThreadLocalAccessor for test class " +
+ testContext.getTestClass().getName());
+ }
+ ObservationThreadLocalAccessor.getInstance().setObservationRegistry(previousObservationRegistry);
+ }
+ }
+
+}
diff --git a/spring-test/src/main/java/org/springframework/test/context/observation/package-info.java b/spring-test/src/main/java/org/springframework/test/context/observation/package-info.java
new file mode 100644
index 0000000000..fecd9bf754
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/context/observation/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Observation support classes for the Spring TestContext Framework.
+ */
+@NonNullApi
+@NonNullFields
+package org.springframework.test.context.observation;
+
+import org.springframework.lang.NonNullApi;
+import org.springframework.lang.NonNullFields;
diff --git a/spring-test/src/main/resources/META-INF/spring.factories b/spring-test/src/main/resources/META-INF/spring.factories
index c18a21abca..2b9e4e3c11 100644
--- a/spring-test/src/main/resources/META-INF/spring.factories
+++ b/spring-test/src/main/resources/META-INF/spring.factories
@@ -5,6 +5,7 @@ org.springframework.test.context.TestExecutionListener = \
org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener,\
org.springframework.test.context.event.ApplicationEventsTestExecutionListener,\
org.springframework.test.context.support.DependencyInjectionTestExecutionListener,\
+ org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener,\
org.springframework.test.context.support.DirtiesContextTestExecutionListener,\
org.springframework.test.context.transaction.TransactionalTestExecutionListener,\
org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\
diff --git a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java
index b07d790797..a1e634c318 100644
--- a/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java
+++ b/spring-test/src/test/java/org/springframework/test/context/TestExecutionListenersTests.java
@@ -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.
@@ -34,6 +34,7 @@ import org.springframework.test.context.support.DirtiesContextBeforeModesTestExe
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
import org.springframework.test.context.web.ServletTestExecutionListener;
+import org.springframework.util.ClassUtils;
import static java.util.Arrays.asList;
import static java.util.stream.Collectors.toList;
@@ -56,12 +57,16 @@ import static org.springframework.test.context.TestExecutionListeners.MergeMode.
*/
class TestExecutionListenersTests {
+ private static final Class> micrometerListenerClass =
+ ClassUtils.resolveClassName("org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener", null);
+
@Test
void defaultListeners() {
List