javajunitresourcebundle

ResourceBundleControlProvider not invoked when running program in an unnamed module under JUnit


When running a test program in an unnamed module under JUnit the ResourceBundleControlProvider.getControl() method is not invoked.

I have this implementation of ResourceBundleControlProvider:

    import java.util.ResourceBundle;
    import java.util.spi.ResourceBundleControlProvider;
    
    /**
     * A ResourceBundleControlProvider implementation
     */
    public class MyControlProvider implements ResourceBundleControlProvider {
        public ResourceBundle.Control getControl(String baseName) {   
            System.out.println("In getControl, baseName: " + baseName);
            return null;
        }   
    }

I have the following in META-INF/services/java.util.spi.ResourceBundleControlProvider:

MyControlProvider:

This test program is intended to demonstrate that MyControlProvider.getControl(String) is invoked:

    import java.util.MissingResourceException;
    import java.util.ResourceBundle;
    import java.util.ServiceLoader;
    import java.util.spi.ResourceBundleControlProvider;
    import org.junit.jupiter.api.Test;
    
    class TestProvider
    {
        /** 
         * Attempt to load a missing resource bundle - should throw
         * MissingResourceException and write a message to stdout.
         */  
        @Test
        public void testProvider() {   
            try {   
                ResourceBundle.getBundle("foo");
            }   
            catch(MissingResourceException e) {   
                System.out.println("Caught: " + e.getMessage());
            }   
            ServiceLoader<ResourceBundleControlProvider> loader =
                ServiceLoader.loadInstalled(ResourceBundleControlProvider.class);
            for (ResourceBundleControlProvider provider : loader) {   
                System.out.println("Found installed provider instance of: " + provider.getClass().getName());
            }   
            ServiceLoader<ResourceBundleControlProvider> loader2 = ServiceLoader.load(ResourceBundleControlProvider.class);
            for (ResourceBundleControlProvider provider : loader2) {   
                System.out.println("Found provider instance of: " + provider.getClass().getName());
            }   
            Module mod = this.getClass().getModule();
            if (mod.isNamed()) {
                System.out.println("Module: " + mod.getName());
            }
            else {
                System.out.println("Unnamed Module: " + mod.toString());
            }
        }
    
        public static void main(String[] args) {
            TestProvider tp = new TestProvider();
            tp.testProvider();
        }
    }

When I run the test program directly, I see that the getControl() method is invoked:

java -cp . TestProvider  
In getControl, baseName: foo  
Caught: Can't find bundle for base name foo, locale en_US  
Found provider instance of: MyControlProvider  
Unnamed Module: unnamed module @5caf905d

When I run the test under JUnit it is not:

java -jar ../junit/junit-platform-console-standalone-1.13.0-M2.jar execute -cp . --scan-classpath

💚 Thanks for using JUnit! Support its development at https://junit.org/sponsoring

Caught: Can't find bundle for base name foo, locale en_US  
Found provider instance of: MyControlProvider  
Unnamed Module: unnamed module @565f390  
â•·  
├─ JUnit Platform Suite ✔  
├─ JUnit Jupiter ✔  
│  └─ TestProvider ✔  
│     └─ testProvider() ✔  
└─ JUnit Vintage ✔

Test run finished after 188 ms

The JDK docs for ResourceBundleControlProvider state that "All ResourceBundleControlProviders are ignored in named modules.". The JUnit test run appears to be running from an unnamed module, so it seems that in this case it should not be ignored.

I am running this version of JUnit, JDK and MacOS:

JUnit Platform Console Launcher 1.13.0-M2

JVM: 21.0.6 (Eclipse Adoptium OpenJDK 64-Bit Server VM 21.0.6+7-LTS)

OS: Mac OS X 15.4.1 aarch64


Solution

  • The problem has to do with how JUnit 5 is loading your code. When you execute this command:

    java -jar ../junit/junit-platform-console-standalone-1.13.0-M2.jar execute -cp . --scan-classpath
    

    Then JUnit 5 has to use its own ClassLoader to load the classes and other resources you specified via -cp. Note that -cp is an argument to the JUnit console launcher, not to Java itself. It's an option used to specify additional class-path entries:

    -cp, --classpath, --class-path=PATH
                           Provide additional classpath entries -- for example, for adding
                             engines and their dependencies. This option can be repeated.
    

    The entries on this path will not be visible to the system class loader. They will only be visible to JUnit's own class loader.

    This is a problem because ResourceBundle apparently hard-codes the use of the system class loader to find providers of ResourceBundleControlProvider. I'm not sure if this is documented somewhere, but you can see this in the source code (link for Java 24):

    private static class ResourceBundleControlProviderHolder {
    
        private static final List<ResourceBundleControlProvider> CONTROL_PROVIDERS =
                ServiceLoader.load(ResourceBundleControlProvider.class,
                                ClassLoader.getSystemClassLoader()).stream()
                        .map(ServiceLoader.Provider::get)
                        .toList();
    
        private static Control getControl(String baseName) {
            return CONTROL_PROVIDERS.isEmpty() ?
                Control.INSTANCE :
                CONTROL_PROVIDERS.stream()
                    .flatMap(provider -> Stream.ofNullable(provider.getControl(baseName)))
                    .findFirst()
                    .orElse(Control.INSTANCE);
        }
    }
    

    Your ResourceBundleControlProvider implementation and service configuration file are part of your test code. That code is being loaded by JUnit's class loader, not the system class loader. Thus, your provider is not found at runtime.

    The solution is to make sure your code, or at least your ResourceBundleControlProvider implementation (and service configuration file), is loaded by the system class loader. One way to do this is with the following command:

    java -cp <path> org.junit.platform.console.ConsoleLauncher --scan-classpath
    

    Note the different location of the -cp option. Now it's a JVM option instead of an argument of the JUnit console launcher application. In other words, the entries of <path>—which includes your code and the JUnit launcher—will be loaded by the system class loader.