spring-modulith icon indicating copy to clipboard operation
spring-modulith copied to clipboard

ApplicationModuleTest with Modulith fails due to configuration import outside application package

Open ghost opened this issue 4 months ago • 3 comments

The following is a simplified version of the issue I have. If I consider the following tree structure

└── root
    │
    ├── modulea
    │   └── package-info.java
    │
    └── moduleb
        ├── AnythingInModuleB.java
        ├── ModuleBConfiguration.java
        └── package-info.java

where I specify in package-info.java ApplicationModule with id modulea and moduleb respectively. Now the content of ModuleBConfiguration is the following (which of course is just for example)

@Configuration
public class ModuleBConfiguration {
    @Bean
    Integer two() {
        return 2;
    }

    @Bean
    AnythingInModuleB anythingInModule() {
        return new AnythingInModuleB();
    }

}

And now in tests I have the following structure

.
└── root
    ├── TestConfiguration.java
    └── modulea
        ├── TestImportB.java

with TestConfiguration annotated with Modulith and TestImportB with the following content

@ApplicationModuleTest
@Import({ModuleBConfiguration.class})
public class TestBeanOutsideB {
    @Test
    void testBeanOutsideB() {}
}

The initialization of this fails with

Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'two' defined in class path resource [root/moduleb/ModuleBConfiguration.class]: Unexpected exception during bean creation
...
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'root.moduleb.ModuleBConfiguration' available

Where does this come from ? Is this expected ? And what surprised me most, is that I only get this is issue with Beans in the configuration that are outside of package root (e.g. Integer), that is AnythingInModuleB works fine.

What is the real use case for this ?

The real use case for this is actually a library that contains two modules and I want to provide both modules with an AutoConfiguration. Hence, I have a structure with a package that contains two modules, both have one auto-configuration (applied through META-INF.spring). In integration tests I set a @Modulith tag to a file in the root package. I considered having instead files with @Modulith tag inside the modules for testing, but then the recognition of the different modules does not work.

ghost avatar Sep 24 '25 13:09 ghost

Any chance you provide a reproducer project (Java, Maven) for this? I can't quite follow. Here are the things I don't understand:

  • where is TestBeanOutsideB located?
  • why does it import configuration from B but is not located in it?

Fundamentally, @ApplicationModuleTest bootstraps the module in which the annotated class is located. I would've expected @Import to directly use the reference class, but there appears to be a reason that it also needs to be available as a bean. I assume that's because, effectively, the bean definition for two has a factory class ModuleBConfiguration with factory method two().

I at least would like to see the full stack trace of the exception to see the reason for the lookup of ModuleBConfiguration as a bean.

As a workaround, you can try to declare the extraIncludes attribute on @ApplicationModuleTest and list moduleB to make sure it gets bootstrapped, too. While that might not be, what we ultimately want in this case, it would be nice to know if this resolves the issue.

odrotbohm avatar Sep 24 '25 14:09 odrotbohm

Thanks for the quick response, here is a link to a reproducer project https://github.com/azd-n-side/spring-modulith-issue-1381. I updated it such to have two configurations with two associated tests to show the difference when the type of the bean is outside the package or not. Here is the stack trace of the test run that fails

Failed to load ApplicationContext for [MergedContextConfiguration@17c4dc5b testClass = root.modulea.TestImportB2, locations = [], classes = [root.TestApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.modulith.test.ModuleContextCustomizerFactory$ModuleContextCustomizer@df07db20, org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@9b9a327, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@31e5417d, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@5b7c8930, [ImportsContextCustomizer@6b0f266e key = [root.moduleb.ModuleBConfig2, org.springframework.modulith.test.ModuleTestAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@30c1da48, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@4e682398, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@79c5460e, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@19bf1633], contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
java.lang.IllegalStateException: Failed to load ApplicationContext for [MergedContextConfiguration@17c4dc5b testClass = root.modulea.TestImportB2, locations = [], classes = [root.TestApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.modulith.test.ModuleContextCustomizerFactory$ModuleContextCustomizer@df07db20, org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@9b9a327, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@31e5417d, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@5b7c8930, [ImportsContextCustomizer@6b0f266e key = [root.moduleb.ModuleBConfig2, org.springframework.modulith.test.ModuleTestAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@30c1da48, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@4e682398, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@79c5460e, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@19bf1633], contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:180)
	at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:130)
	at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.injectDependencies(DependencyInjectionTestExecutionListener.java:155)
	at org.springframework.test.context.support.DependencyInjectionTestExecutionListener.prepareTestInstance(DependencyInjectionTestExecutionListener.java:111)
	at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:260)
	at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:159)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:179)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1708)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at java.base/java.util.Optional.orElseGet(Optional.java:364)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'two' defined in class path resource [root/moduleb/ModuleBConfig2.class]: Unexpected exception during bean creation
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339)
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.instantiateSingleton(DefaultListableBeanFactory.java:1221)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingleton(DefaultListableBeanFactory.java:1187)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:1123)
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:987)
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:627)
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752)
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:318)
	at org.springframework.boot.test.context.SpringBootContextLoader.lambda$loadContext$3(SpringBootContextLoader.java:144)
	at org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:58)
	at org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:46)
	at org.springframework.boot.SpringApplication.withHook(SpringApplication.java:1461)
	at org.springframework.boot.test.context.SpringBootContextLoader$ContextLoaderHook.run(SpringBootContextLoader.java:563)
	at org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:144)
	at org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:110)
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:225)
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:152)
	... 18 more
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'root.moduleb.ModuleBConfig2' available
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanDefinition(DefaultListableBeanFactory.java:978)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getMergedLocalBeanDefinition(AbstractBeanFactory.java:1381)
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:302)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529)
	... 39 more

ghost avatar Sep 24 '25 15:09 ghost

Hi,

I dug a bit into this issue and understood a bit more about what is happening here. In summary, it's a mix of misconfiguration on our side and what looks like a bug in ModuleTestExecutionBeanDefinitionSelector.

So first, a bit of context on how we ended up with this issue. What we were trying to do was to create some generic modules in their own repo/package, then use those modules in another repo under a different package. To register those generic modules, we used the additionalPackages of the @Modulith annotation, but we also had to tell Spring to scan this extra package.

We initially just did it the following way:

@Modulith(systemName = "Modulith Demo" , additionalPackages = "shared")
@ComponentScan(basePackages = {"shared", "app.example"})

And it led to the issue described above. The reason is that @ApplicationModuleTest registers a TypeExcludeFilters (ModuleTypeExcludeFilter to be precise), which is supposed to exclude beans from modules that are outside the scope of the test. But as we did not register the exclude filter TypeExcludeFilter in our @ComponentScan, it was never triggered and all beans were scanned.

With the following config, it works fine

@Modulith(systemName = "Modulith Demo" , additionalPackages = "shared")
@ComponentScan(
    basePackages = {"shared", "app.example"},
    excludeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })

Now, for me, there is still an issue. If, for any reason, some beans from modules outside of the scope of the current test are loaded in the Spring Bean definition registry. This can happen for any reason, for example:

  • you forget to register TypeExcludeFilter like us
  • you load some external modules with auto-configuration and not using component scanning
  • you manually import some configuration class like in the example in the first post here above

If this happens, then Spring Modulith has a second mechanism to filter out beans from unwanted modules. A ModuleTestExecutionBeanDefinitionSelector is registered by ModuleContextCustomizerFactory. This class basically go through the list of registered bean definitions and removes all bean definitions that are linked to beans of a type belonging to the unwanted modules. That logic is not sufficient and causes the error described above. Indeed, if an unwanted module contains a configuration class, for example AuditConfig, that contains a bean method with a type that does not belong to that module, for example public AuditorAware<UUID> auditorProvider() {...}, then ModuleTestExecutionBeanDefinitionSelector will filter out the bean of the configuration class but not the method bean. When Spring tries later on to instantiate the method bean, it fails as the bean of the configuration class is not there anymore.

I would expect ModuleTestExecutionBeanDefinitionSelector to be a bit smarter and filter out both bean definitions for beans of type or declared by a configuration class of type belonging to unwanted modules.

I implemented a custom ModuleTestExecutionBeanDefinitionSelector which does that, and it also fixes the error described above. It basically does the same as the original but uses the following method to identify all modules a given bean is related to, based on its type and its declaring class, if any.

private Stream<ApplicationModule> findModules(
        String beanName, ConfigurableListableBeanFactory factory, ApplicationModules modules) {

      var type = Optional.ofNullable(factory.getType(beanName, false));
      var typeModule = type.flatMap(modules::getModuleByType).stream();

      var bd = factory.getBeanDefinition(beanName);
      if (bd instanceof AnnotatedBeanDefinition abd && abd.getFactoryMethodMetadata() != null) {
        var declaringType = abd.getFactoryMethodMetadata().getDeclaringClassName();
        var declaringModule = modules.getModuleByType(declaringType).stream();
        return Stream.concat(typeModule, declaringModule).distinct();
      } else {
        return typeModule;
      }
    }

I updated the spring-modulith-issue-1381 with examples. There are three tests:

  • one with the proper config with the TypeExcludeFilter registered, all works fine.
  • one without TypeExcludeFilter that leads to the error described above.
  • one without TypeExcludeFilter and the fixed ModuleTestExecutionBeanDefinitionSelector that also works fine.

ddeSide avatar Oct 22 '25 08:10 ddeSide