This description was written by Claude Opus 4.8 and reviewed by me
Summary
Using ApplicationContextRunner with FilteredClassLoader is great for asserting @ConditionalOnClass/@ConditionalOnMissingClass behaviour. However, it cannot reproduce this type of failure: NoClassDefFoundError thrown while Spring reflects over the methods of a configuration class that references a type from an optional dependency.
I'd like to propose adding a complementary test utility (working name: HidingClassLoader) that can act as the defining class loader of selected classes while pretending a set of other classes is absent.
Motivating real-world scenario
An auto-configuration declares a @Bean whose return type comes from an optional dependency (here, spring-web, pulled in transitively only when spring-boot-starter-webmvc is present):
@AutoConfiguration
public class MyMetricsAutoConfiguration {
@Bean
OpenTelemetryServerRequestObservationConvention convention() { // from spring-web (optional)
return new OpenTelemetryServerRequestObservationConvention();
}
@Bean
@ConditionalOnBean(SomethingElse.class)
MyOtherBean myOtherBean(SomethingElse dep) { ... }
}
When an application does not depend on spring-web, startup fails hard:
Error processing condition on ...MyMetricsAutoConfiguration.myOtherBean
Caused by: IllegalStateException: Failed to introspect Class [MyMetricsAutoConfiguration]
Caused by: NoClassDefFoundError: org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention
Caused by: ClassNotFoundException: ...OpenTelemetryServerRequestObservationConvention
The root cause: evaluating the condition on the unrelated myOtherBean method triggers OnBeanCondition → ReflectionUtils.getUniqueDeclaredMethods → Class.getDeclaredMethods(), which must resolve all declared method return types — including the missing convention() return type — regardless of any conditions. This is a common, easy-to-introduce bug in libraries/starters, and it would be very valuable to be able to write a regression test for it.
Why FilteredClassLoader cannot reproduce this
FilteredClassLoader is constructed as super(new URL[0], <parent>) — it has no URLs of its own and therefore always delegates class definition to its parent (the application class loader). It only intercepts loads to throw ClassNotFoundException for filtered names.
Consequently, in a test like:
new ApplicationContextRunner()
.withClassLoader(new FilteredClassLoader(OpenTelemetryServerRequestObservationConvention.class))
.withConfiguration(AutoConfigurations.of(MyMetricsAutoConfiguration.class))
.run(context -> assertThat(context).hasNotFailed());
MyMetricsAutoConfiguration is still defined by the application class loader (which does have spring-web). So when Spring calls getDeclaredMethods(), the return types are resolved through that defining loader, the "hidden" class is found, and no NoClassDefFoundError occurs. The condition checks are simulated correctly, but the reflection-over-methods failure is not — the very failure we need to test never happens.
Proposed solution
A test ClassLoader that:
- Owns the application classpath (real URLs), so it can define classes itself.
- Is child-first (self-first) for a configurable set of class names, so it becomes the defining loader of those classes (e.g. the auto-configuration under test).
- Hides a configurable set of classes (throws
ClassNotFoundException), simulating a missing optional dependency.
- Delegates everything else to the parent (the application class loader), so shared libraries aren't duplicated and there are no cross-loader type-identity issues.
Sketch of the behaviour I currently use as a workaround:
final class HidingClassLoader extends URLClassLoader {
private final Set<String> hidden; // pretend these are absent
private final Set<String> loadedLocally; // define these ourselves (child-first)
HidingClassLoader(URL[] urls, ClassLoader parent, Set<String> loadedLocally, String... hidden) {
super(urls, parent);
this.loadedLocally = Set.copyOf(loadedLocally);
this.hidden = Set.of(hidden);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (hidden.contains(name)) {
throw new ClassNotFoundException(name);
}
if (shouldLoadLocally(name)) {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
c = findClass(name); // define via our own URLs
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
return super.loadClass(name, resolve);
}
private boolean shouldLoadLocally(String name) {
return loadedLocally.stream()
.anyMatch(local -> name.equals(local) || name.startsWith(local + "$"));
}
}
Used with ApplicationContextRunner:
try (HidingClassLoader cl = new HidingClassLoader(
applicationClasspath(), getClass().getClassLoader(),
Set.of("com.example.MyMetricsAutoConfiguration"),
"org.springframework.http.server.observation.OpenTelemetryServerRequestObservationConvention")) {
Class<?> autoConfig = cl.loadClass("com.example.MyMetricsAutoConfiguration");
new ApplicationContextRunner()
.withClassLoader(cl)
.withConfiguration(AutoConfigurations.of(autoConfig))
.run(context -> assertThat(context).hasNotFailed()); // fails before fix, passes after
}
This reproduces the real NoClassDefFoundError startup failure and turns it into a straightforward pass/fail regression test.
How this differs from FilteredClassLoader (at a glance)
| Aspect |
FilteredClassLoader |
Proposed HidingClassLoader |
| Has own URLs / classpath |
No (new URL[0]) |
Yes (full app classpath) |
| Can be the defining loader of loaded classes |
No (delegates to parent) |
Yes (child-first for a selected set) |
Simulates absence for @ConditionalOnClass (name lookups) |
Yes |
Yes |
Reproduces NoClassDefFoundError during reflection over method signatures |
No |
Yes |
Alternatives considered
- Extending
FilteredClassLoader to add child-first defining behaviour — possibly the cleanest option if you'd prefer one class with an extra mode, rather than a new type.
- Excluding the optional jar from the test classpath via a separate Surefire/Maven module — heavyweight, hard to target per-test, and doesn't compose with
ApplicationContextRunner.
- Keeping this in each project's test sources — works, but the subtlety (why
FilteredClassLoader is insufficient, correct child-first + delegation to avoid duplicate types) is easy to get wrong and seems generally useful, hence this request.
This description was written by Claude Opus 4.8 and reviewed by me
Summary
Using
ApplicationContextRunnerwithFilteredClassLoaderis great for asserting@ConditionalOnClass/@ConditionalOnMissingClassbehaviour. However, it cannot reproduce this type of failure:NoClassDefFoundErrorthrown while Spring reflects over the methods of a configuration class that references a type from an optional dependency.I'd like to propose adding a complementary test utility (working name:
HidingClassLoader) that can act as the defining class loader of selected classes while pretending a set of other classes is absent.Motivating real-world scenario
An auto-configuration declares a
@Beanwhose return type comes from an optional dependency (here,spring-web, pulled in transitively only whenspring-boot-starter-webmvcis present):When an application does not depend on
spring-web, startup fails hard:The root cause: evaluating the condition on the unrelated
myOtherBeanmethod triggersOnBeanCondition→ReflectionUtils.getUniqueDeclaredMethods→Class.getDeclaredMethods(), which must resolve all declared method return types — including the missingconvention()return type — regardless of any conditions. This is a common, easy-to-introduce bug in libraries/starters, and it would be very valuable to be able to write a regression test for it.Why
FilteredClassLoadercannot reproduce thisFilteredClassLoaderis constructed assuper(new URL[0], <parent>)— it has no URLs of its own and therefore always delegates class definition to its parent (the application class loader). It only intercepts loads to throwClassNotFoundExceptionfor filtered names.Consequently, in a test like:
MyMetricsAutoConfigurationis still defined by the application class loader (which does havespring-web). So when Spring callsgetDeclaredMethods(), the return types are resolved through that defining loader, the "hidden" class is found, and noNoClassDefFoundErroroccurs. The condition checks are simulated correctly, but the reflection-over-methods failure is not — the very failure we need to test never happens.Proposed solution
A test
ClassLoaderthat:ClassNotFoundException), simulating a missing optional dependency.Sketch of the behaviour I currently use as a workaround:
Used with
ApplicationContextRunner:This reproduces the real
NoClassDefFoundErrorstartup failure and turns it into a straightforward pass/fail regression test.How this differs from
FilteredClassLoader(at a glance)FilteredClassLoaderHidingClassLoadernew URL[0])@ConditionalOnClass(name lookups)NoClassDefFoundErrorduring reflection over method signaturesAlternatives considered
FilteredClassLoaderto add child-first defining behaviour — possibly the cleanest option if you'd prefer one class with an extra mode, rather than a new type.ApplicationContextRunner.FilteredClassLoaderis insufficient, correct child-first + delegation to avoid duplicate types) is easy to get wrong and seems generally useful, hence this request.