Skip to content

Provide a test ClassLoader that can be the defining loader of hidden classes #51377

Description

@wimdeblauwe

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 OnBeanConditionReflectionUtils.getUniqueDeclaredMethodsClass.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:

  1. Owns the application classpath (real URLs), so it can define classes itself.
  2. 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).
  3. Hides a configurable set of classes (throws ClassNotFoundException), simulating a missing optional dependency.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    for: team-meetingAn issue we'd like to discuss as a team to make progressstatus: waiting-for-triageAn issue we've not yet triaged

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions