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} *
  • {@link org.springframework.test.context.support.DependencyInjectionTestExecutionListener * DependencyInjectionTestExecutionListener}
  • + *
  • {@link org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener + * MicrometerObservationRegistryTestExecutionListener}
  • *
  • {@link org.springframework.test.context.support.DirtiesContextTestExecutionListener * DirtiesContextTestExecutionListener}
  • *
  • {@link org.springframework.test.context.transaction.TransactionalTestExecutionListener diff --git a/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java b/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java new file mode 100644 index 0000000000..97873c1a09 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java @@ -0,0 +1,142 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.observation; + +import java.lang.reflect.Method; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.platform.launcher.TestExecutionListener; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.Conventions; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * {@code TestExecutionListener} which provides support for Micrometer's + * {@link ObservationRegistry}. + * + *

    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> expected = asList(ServletTestExecutionListener.class,// DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// + micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// @@ -80,6 +85,7 @@ class TestExecutionListenersTests { DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// + micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// @@ -97,6 +103,7 @@ class TestExecutionListenersTests { DirtiesContextBeforeModesTestExecutionListener.class,// ApplicationEventsTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// + micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// TransactionalTestExecutionListener.class, SqlScriptsTestExecutionListener.class,// @@ -116,6 +123,7 @@ class TestExecutionListenersTests { ApplicationEventsTestExecutionListener.class,// DependencyInjectionTestExecutionListener.class,// BarTestExecutionListener.class,// + micrometerListenerClass,// DirtiesContextTestExecutionListener.class,// TransactionalTestExecutionListener.class,// SqlScriptsTestExecutionListener.class,// @@ -361,9 +369,9 @@ class TestExecutionListenersTests { @Override public int getOrder() { - // 2500 is between DependencyInjectionTestExecutionListener (2000) and - // DirtiesContextTestExecutionListener (3000) - return 2500; + // 2250 is between DependencyInjectionTestExecutionListener (2000) and + // MicrometerObservationRegistryTestExecutionListener (2500) + return 2250; } } diff --git a/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerTests.java b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerTests.java new file mode 100644 index 0000000000..46ab3cf5de --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerTests.java @@ -0,0 +1,107 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.observation; + +import java.util.HashMap; +import java.util.Map; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link MicrometerObservationRegistryTestExecutionListener}. + * + * @author Marcin Grzejszczak + * @author Sam Brannen + * @since 6.0.10 + */ +class MicrometerObservationRegistryTestExecutionListenerTests { + + private final ObservationRegistry originalObservationRegistry = globalObservationRegistry(); + + private final TestContext testContext = mock(); + + private final StaticApplicationContext applicationContext = new StaticApplicationContext(); + + private final Map attributes = new HashMap<>(); + + private final TestExecutionListener listener = new MicrometerObservationRegistryTestExecutionListener(); + + + @BeforeEach + @SuppressWarnings({ "unchecked", "rawtypes" }) // for raw Class testClass + void configureTestContextMock() { + willAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .given(testContext).setAttribute(anyString(), any()); + given(testContext.removeAttribute(anyString())) + .willAnswer(invocation -> attributes.get(invocation.getArgument(0, String.class))); + given(testContext.getApplicationContext()).willReturn(applicationContext); + Class testClass = getClass(); + given(testContext.getTestClass()).willReturn(testClass); + } + + @Test + void observationRegistryIsNotOverridden() throws Exception { + assertGlobalObservationRegistryIsSameAsOriginal(); + + listener.beforeTestMethod(testContext); + assertGlobalObservationRegistryIsSameAsOriginal(); + + listener.afterTestMethod(testContext); + assertGlobalObservationRegistryIsSameAsOriginal(); + } + + @Test + void observationRegistryIsOverriddenByBeanFromApplicationContext() throws Exception { + assertGlobalObservationRegistryIsSameAsOriginal(); + + ObservationRegistry testObservationRegistry = ObservationRegistry.create(); + applicationContext.getDefaultListableBeanFactory().registerSingleton("observationRegistry", testObservationRegistry); + + listener.beforeTestMethod(testContext); + ObservationRegistry globalObservationRegistry = globalObservationRegistry(); + assertThat(globalObservationRegistry) + .as("The global ObservationRegistry should have been replaced with the one from the application context") + .isNotSameAs(originalObservationRegistry) + .isSameAs(testObservationRegistry); + + listener.afterTestMethod(testContext); + assertGlobalObservationRegistryIsSameAsOriginal(); + } + + private void assertGlobalObservationRegistryIsSameAsOriginal() { + assertThat(globalObservationRegistry()).isSameAs(originalObservationRegistry); + } + + private static ObservationRegistry globalObservationRegistry() { + return ObservationThreadLocalAccessor.getInstance().getObservationRegistry(); + } + +} diff --git a/spring-test/src/test/resources/log4j2-test.xml b/spring-test/src/test/resources/log4j2-test.xml index 88cf6a3b9a..15645c85eb 100644 --- a/spring-test/src/test/resources/log4j2-test.xml +++ b/spring-test/src/test/resources/log4j2-test.xml @@ -18,6 +18,7 @@ +