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 index 05ee3cc449d..58a4ef4b95b 100644 --- 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 @@ -36,7 +36,8 @@ import org.springframework.util.ReflectionUtils; * *

This listener updates the {@link ObservationThreadLocalAccessor} with the * {@code ObservationRegistry} obtained from the test's {@link ApplicationContext}, - * if present. + * if the {@code ApplicationContext} is available and contains an + * {@code ObservationRegistry} bean. * * @author Marcin Grzejszczak * @author Sam Brannen @@ -125,9 +126,10 @@ class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExe } /** - * 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 + * If the test's {@link ApplicationContext} is available and 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}. @@ -136,6 +138,9 @@ class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExe */ @Override public void beforeTestMethod(TestContext testContext) { + if (!testContext.hasApplicationContext()) { + return; + } testContext.getApplicationContext().getBeanProvider(ObservationRegistry.class) .ifAvailable(registry -> { if (logger.isDebugEnabled()) { 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 index 049286c3499..0d2b7f4d366 100644 --- 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 @@ -34,6 +34,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; /** * Tests for {@link MicrometerObservationRegistryTestExecutionListener}. @@ -41,6 +43,8 @@ import static org.mockito.Mockito.mock; * @author Marcin Grzejszczak * @author Sam Brannen * @since 6.0.10 + * @see MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests + * @see MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests */ class MicrometerObservationRegistryTestExecutionListenerTests { @@ -62,11 +66,31 @@ class MicrometerObservationRegistryTestExecutionListenerTests { .given(testContext).setAttribute(anyString(), any()); given(testContext.removeAttribute(anyString())) .willAnswer(invocation -> attributes.get(invocation.getArgument(0, String.class))); + given(testContext.hasApplicationContext()).willReturn(true); given(testContext.getApplicationContext()).willReturn(applicationContext); Class testClass = getClass(); given(testContext.getTestClass()).willReturn(testClass); } + @Test // gh-36817 + void beforeTestMethodIsNoOpWhenContextIsNotAvailable() throws Exception { + given(testContext.hasApplicationContext()).willReturn(false); + + listener.beforeTestMethod(testContext); + + verify(testContext).hasApplicationContext(); + verify(testContext, never()).getApplicationContext(); + } + + @Test // gh-36817 + void afterTestMethodIsNoOpWhenContextIsNotAvailable() throws Exception { + given(testContext.hasApplicationContext()).willReturn(false); + + listener.afterTestMethod(testContext); + + verify(testContext, never()).getApplicationContext(); + } + @Test void observationRegistryIsNotOverridden() throws Exception { assertGlobalObservationRegistryIsSameAsOriginal(); diff --git a/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests.java b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests.java new file mode 100644 index 00000000000..b58687c9cf1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-present 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 org.junit.jupiter.api.Test; +import org.testng.TestNG; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.springframework.test.context.testng.TrackingTestNGTestListener; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * JUnit based integration test which verifies that + * {@link MicrometerObservationRegistryTestExecutionListener} — when used in + * conjunction with Spring's TestNG support — does not attempt to load an + * application context to update the + * {@link io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor} + * if the application context is not currently loaded or previously failed to load. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36817 + * @see MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests + */ +class MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests { + + /** + *

NOTE: The {@code @BeforeMethod(alwaysRun = true)} and {@code @AfterMethod(alwaysRun = true)} + * lifecycle methods in {@link AbstractTestNGSpringContextTests} are always invoked, even if a + * previous lifecycle configuration method failed (for example, due to a context-load failure). + */ + @Test + void contextLoadFailureCausesExpectedTestFailures() { + TrackingTestNGTestListener listener = new TrackingTestNGTestListener(); + TestNG testNG = new TestNG(); + testNG.addListener(listener); + testNG.setTestClasses(new Class[] { ContextLoadFailureTestCase.class }); + testNG.setVerbose(0); + testNG.run(); + + assertThat(listener.testStartCount).as("tests started").hasValue(2); + assertThat(listener.testSuccessCount).as("tests succeeded").hasValue(0); + assertThat(listener.testFailureCount).as("tests failed").hasValue(0); + // Before the introduction of the hasApplicationContext() check in + // MicrometerObservationRegistryTestExecutionListener, the @BeforeMethod lifecycle + // method in AbstractTestNGSpringContextTests also attempted to load the faulty + // ApplicationContext, resulting in 3 configuration failures: + // 1 * @BeforeClass + 2 * @BeforeMethod = 3. + // With the fix, only the @BeforeClass context-load failure is recorded. + assertThat(listener.failedConfigurationsCount).as("failed configurations").hasValue(1); + } + + + /** + *

The {@code @TestExecutionListeners} declaration replaces the default listeners with + * only those needed to exercise {@link MicrometerObservationRegistryTestExecutionListener}, + * ensuring that no additional listeners call {@code testContext.getApplicationContext()} + * without a conditional {@code hasApplicationContext()} check. + */ + @ContextConfiguration + @TestExecutionListeners({ + DependencyInjectionTestExecutionListener.class, + MicrometerObservationRegistryTestExecutionListener.class + }) + static class ContextLoadFailureTestCase extends AbstractTestNGSpringContextTests { + + @org.testng.annotations.Test + void test1() { + } + + @org.testng.annotations.Test + void test2() { + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + String alwaysFails() { + throw new RuntimeException("Simulated context load failure"); + } + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests.java b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests.java new file mode 100644 index 00000000000..6289f861f49 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-present 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 org.junit.jupiter.api.MethodOrderer.MethodName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.platform.testkit.engine.EngineTestKit; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.EventConditions.test; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +/** + * Tests which indirectly verify that {@link MicrometerObservationRegistryTestExecutionListener} + * does not attempt to load an application context to update the + * {@link io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor} if + * the application context is not currently loaded or previously failed to load. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36817 + * @see MicrometerObservationRegistryTestExecutionListenerTests + * @see MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTestNGTests + */ +class MicrometerObservationRegistryTestExecutionListenerWithContextLoadFailureTests { + + @Test + void contextLoadFailureCausesExpectedTestFailures() { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(ContextLoadFailureTestCase.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(2).succeeded(0).failed(2)) + .assertThatEvents() + .haveExactly(1, event(test("test1"), + finishedWithFailure( + instanceOf(IllegalStateException.class), + message(msg -> msg.startsWith("Failed to load ApplicationContext"))))) + .haveExactly(1, event(test("test2"), + finishedWithFailure( + instanceOf(IllegalStateException.class), + message(msg -> msg.contains("failure threshold"))))); + } + + + /** + *

The {@code @TestExecutionListeners} declaration replaces the default listeners with + * only those needed to exercise {@link MicrometerObservationRegistryTestExecutionListener}, + * ensuring that no additional listeners call {@code testContext.getApplicationContext()} + * without a conditional {@code hasApplicationContext()} check. + */ + @SpringJUnitConfig + @TestExecutionListeners({ + DependencyInjectionTestExecutionListener.class, + MicrometerObservationRegistryTestExecutionListener.class + }) + @TestMethodOrder(MethodName.class) + static class ContextLoadFailureTestCase { + + @Test + void test1() { + } + + @Test + void test2() { + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + String alwaysFails() { + throw new RuntimeException("Simulated context load failure"); + } + } + } + +}