Skip to content

Commit 5aac35b

Browse files
committed
Check deps only once in MicrometerObservationRegistryTestExecutionListener
Prior to this commit, the required runtime dependencies were checked via reflection each time an attempt was made to instantiate MicrometerObservationRegistryTestExecutionListener. Since it's sufficient to check for the presence of required runtime dependencies only once, this commit caches the results of the dependency checks in a static field. This commit also introduces automated tests for the runtime dependency checks in MicrometerObservationRegistryTestExecutionListener. See gh-30747
1 parent 040ea0a commit 5aac35b

File tree

2 files changed

+142
-33
lines changed

2 files changed

+142
-33
lines changed

spring-test/src/main/java/org/springframework/test/context/observation/MicrometerObservationRegistryTestExecutionListener.java

+30-33
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
package org.springframework.test.context.observation;
1818

19-
import java.lang.reflect.Method;
20-
2119
import io.micrometer.observation.ObservationRegistry;
2220
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
2321
import org.apache.commons.logging.Log;
@@ -27,7 +25,6 @@
2725
import org.springframework.core.Conventions;
2826
import org.springframework.test.context.TestContext;
2927
import org.springframework.test.context.support.AbstractTestExecutionListener;
30-
import org.springframework.util.ReflectionUtils;
3128

3229
/**
3330
* {@code TestExecutionListener} which provides support for Micrometer's
@@ -45,11 +42,6 @@ class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExe
4542

4643
private static final Log logger = LogFactory.getLog(MicrometerObservationRegistryTestExecutionListener.class);
4744

48-
private static final String THREAD_LOCAL_ACCESSOR_CLASS_NAME = "io.micrometer.context.ThreadLocalAccessor";
49-
50-
private static final String OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME =
51-
"io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor";
52-
5345
/**
5446
* Attribute name for a {@link TestContext} attribute which contains the
5547
* {@link ObservationRegistry} that was previously stored in the
@@ -62,41 +54,46 @@ class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExe
6254
private static final String PREVIOUS_OBSERVATION_REGISTRY = Conventions.getQualifiedAttributeName(
6355
MicrometerObservationRegistryTestExecutionListener.class, "previousObservationRegistry");
6456

57+
static final String DEPENDENCIES_ERROR_MESSAGE = """
58+
MicrometerObservationRegistryTestExecutionListener requires \
59+
io.micrometer:micrometer-observation:1.10.8 or higher and \
60+
io.micrometer:context-propagation:1.0.3 or higher.""";
6561

66-
public MicrometerObservationRegistryTestExecutionListener() {
62+
static final String THREAD_LOCAL_ACCESSOR_CLASS_NAME = "io.micrometer.context.ThreadLocalAccessor";
63+
64+
static final String OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME =
65+
"io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor";
66+
67+
private static final String ERROR_MESSAGE;
68+
69+
static {
6770
// Trigger eager resolution of Micrometer Observation types to ensure that
6871
// this listener can be properly skipped when SpringFactoriesLoader attempts
6972
// to load it -- for example, if context-propagation and micrometer-observation
7073
// are not in the classpath or if the version of ObservationThreadLocalAccessor
7174
// present does not include the getObservationRegistry() method.
72-
ClassLoader classLoader = getClass().getClassLoader();
73-
String errorMessage = """
74-
MicrometerObservationRegistryTestExecutionListener requires \
75-
io.micrometer:micrometer-observation:1.10.8 or higher and \
76-
io.micrometer:context-propagation:1.0.3 or higher.""";
7775

76+
String errorMessage = null;
77+
ClassLoader classLoader = MicrometerObservationRegistryTestExecutionListener.class.getClassLoader();
78+
String classToCheck = THREAD_LOCAL_ACCESSOR_CLASS_NAME;
7879
try {
79-
Class.forName(THREAD_LOCAL_ACCESSOR_CLASS_NAME, false, classLoader);
80-
Class<?> clazz = Class.forName(OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME, false, classLoader);
81-
Method method = ReflectionUtils.findMethod(clazz, "getObservationRegistry");
82-
if (method == null) {
83-
// We simulate "class not found" even though it's "method not found", since
84-
// the ClassNotFoundException will be processed in the subsequent catch-block.
85-
throw new ClassNotFoundException(OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME);
86-
}
80+
Class.forName(classToCheck, false, classLoader);
81+
classToCheck = OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME;
82+
Class<?> clazz = Class.forName(classToCheck, false, classLoader);
83+
clazz.getMethod("getObservationRegistry");
8784
}
8885
catch (Throwable ex) {
89-
// Ensure that we throw a NoClassDefFoundError so that the exception will be properly
90-
// handled in TestContextFailureHandler.
91-
// If the original exception was a ClassNotFoundException or NoClassDefFoundError,
92-
// we throw a NoClassDefFoundError with an augmented error message and omit the cause.
93-
if (ex instanceof ClassNotFoundException || ex instanceof NoClassDefFoundError) {
94-
throw new NoClassDefFoundError(ex.getMessage() + ". " + errorMessage);
95-
}
96-
// Otherwise, we throw a NoClassDefFoundError with the cause initialized.
97-
Error error = new NoClassDefFoundError(errorMessage);
98-
error.initCause(ex);
99-
throw error;
86+
errorMessage = classToCheck + ". " + DEPENDENCIES_ERROR_MESSAGE;
87+
}
88+
ERROR_MESSAGE = errorMessage;
89+
}
90+
91+
92+
public MicrometerObservationRegistryTestExecutionListener() {
93+
// If required dependencies are missing, throw a NoClassDefFoundError so
94+
// that this listener will be properly skipped in TestContextFailureHandler.
95+
if (ERROR_MESSAGE != null) {
96+
throw new NoClassDefFoundError(ERROR_MESSAGE);
10097
}
10198
}
10299

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.context.observation;
18+
19+
import java.lang.reflect.Constructor;
20+
import java.lang.reflect.InvocationTargetException;
21+
import java.util.function.Predicate;
22+
import java.util.stream.IntStream;
23+
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.core.OverridingClassLoader;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
30+
import static org.assertj.core.api.Assertions.assertThatNoException;
31+
import static org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener.DEPENDENCIES_ERROR_MESSAGE;
32+
import static org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener.OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME;
33+
import static org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener.THREAD_LOCAL_ACCESSOR_CLASS_NAME;
34+
35+
/**
36+
* Unit tests for {@link MicrometerObservationRegistryTestExecutionListener}
37+
* behavior regarding required dependencies.
38+
*
39+
* @author Sam Brannen
40+
* @since 6.0.11
41+
*/
42+
class MicrometerObservationRegistryTestExecutionListenerDependencyTests {
43+
44+
@Test
45+
void allDependenciesArePresent() throws Exception {
46+
FilteringClassLoader classLoader = new FilteringClassLoader(getClass().getClassLoader(), name -> false);
47+
Class<?> listenerClass = classLoader.loadClass(MicrometerObservationRegistryTestExecutionListener.class.getName());
48+
// Invoke multiple times to ensure consistency.
49+
IntStream.rangeClosed(1, 5).forEach(n -> assertThatNoException().isThrownBy(() -> instantiateListener(listenerClass)));
50+
}
51+
52+
@Test
53+
void threadLocalAccessorIsNotPresent() throws Exception {
54+
assertNoClassDefFoundErrorIsThrown(THREAD_LOCAL_ACCESSOR_CLASS_NAME);
55+
}
56+
57+
@Test
58+
void observationThreadLocalAccessorIsNotPresent() throws Exception {
59+
assertNoClassDefFoundErrorIsThrown(OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME);
60+
}
61+
62+
private void assertNoClassDefFoundErrorIsThrown(String missingClassName) throws Exception {
63+
FilteringClassLoader classLoader = new FilteringClassLoader(getClass().getClassLoader(), missingClassName::equals);
64+
Class<?> listenerClass = classLoader.loadClass(MicrometerObservationRegistryTestExecutionListener.class.getName());
65+
// Invoke multiple times to ensure the same error message is generated every time.
66+
IntStream.rangeClosed(1, 5).forEach(n -> assertExceptionThrown(missingClassName, listenerClass));
67+
}
68+
69+
private void assertExceptionThrown(String missingClassName, Class<?> listenerClass) {
70+
assertThatExceptionOfType(InvocationTargetException.class)
71+
.isThrownBy(() -> instantiateListener(listenerClass))
72+
.havingCause()
73+
.isInstanceOf(NoClassDefFoundError.class)
74+
.withMessage(missingClassName + ". " + DEPENDENCIES_ERROR_MESSAGE);
75+
}
76+
77+
private void instantiateListener(Class<?> listenerClass) throws Exception {
78+
assertThat(listenerClass).isNotNull();
79+
Constructor<?> constructor = listenerClass.getDeclaredConstructor();
80+
constructor.setAccessible(true);
81+
constructor.newInstance();
82+
}
83+
84+
85+
static class FilteringClassLoader extends OverridingClassLoader {
86+
87+
private static final Predicate<? super String> isListenerClass =
88+
MicrometerObservationRegistryTestExecutionListener.class.getName()::equals;
89+
90+
private final Predicate<String> classNameFilter;
91+
92+
93+
FilteringClassLoader(ClassLoader parent, Predicate<String> classNameFilter) {
94+
super(parent);
95+
this.classNameFilter = classNameFilter;
96+
}
97+
98+
@Override
99+
protected boolean isEligibleForOverriding(String className) {
100+
return this.classNameFilter.or(isListenerClass).test(className);
101+
}
102+
103+
@Override
104+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
105+
if (this.classNameFilter.test(name)) {
106+
throw new ClassNotFoundException(name);
107+
}
108+
return super.loadClass(name, resolve);
109+
}
110+
}
111+
112+
}

0 commit comments

Comments
 (0)