javaspring-bootunit-testingspring-boot-testspring-context

Spring Boot tests: exclude one specific @Configuration class without using the @Profile annotation


My situation is this: I have a @SpringBootApplication class that defines some beans and configurations that are needed both at runtime and during tests, and (in the same package) a @Configuration class that defines the application's own beans.
In unit tests, there are several of those beans that cannot be initialized and that I need to re-define and (partially) mock in a @TestConfiguration class.

Now, using the @Profile annotation would easily solve my problem but I'd prefer not writing any test-related code in the main application code. That includes the "test" profile in that annotation (or, well, "!test" I guess). Also I've read that using it to manage beans like this is an anti-pattern.

Here's some pseudo-code to make things clearer before saying what I tried:

Application class:

@SpringBootApplication
public class MyApplication extends SpringBootServletInitializer implements WebMvcConfigurer {

    public static void main(String[] args) throws IOException {
        SpringApplication.run(MyApplication.class, args);
    }

    // overrides some configuration methods and default Spring beans
}

Configuration class with the app's own beans:

@Configuration
public class BeansConfiguration {

    @Bean
    BeanInterface1 bean1(DataSource dataSource) {
        return new BeanClass1(dataSource);
    }

    @Bean
    BeanInterface2 bean2(SomeOtherDependency otherDep) {
        return new BeanClass2(otherDep);
    }

    // other beans...
}

Test configuration class that redefines these beans:

@TestConfiguration
public class TestBeansConfiguration {

    @Bean
    @Primary
    BeanInterface1 bean1(DataSource dataSource) {
        // partial mock of the "BeanClass1" class, with a lot of custom code for tests
        return new TestBeanClass1(dataSource);
    }

    @Bean
    @Primary
    BeanInterface2 bean2(SomeOtherDependency otherDep) {
        return new TestBeanClass2(otherDep);// same here
    }

    // other beans more or less like this...
}

Annotations on the abstract test class that is extended by most test classes:

@AutoConfigureMockMvc
@TestInstance(Lifecycle.PER_CLASS)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class MyTestClass {
    // ...
}

If you noticed the @Primary annotation on the test beans: it would work if I didn't have some beans in the BeansConfiguration class that completely fail to initialize and throw an exception in the test environment...

Now, before you ask me "why aren't you mocking these beans instead?" it's because this application didn't have Spring, previously. There was A LOT of test code in the "manual mocks" that were made, that can't just be thrown away. I've already adapted all such code to work in @SpringBootTests, but I'm having trouble here initializing the Spring context.

One thing that I tried was defining the "classes" field in the @SpringBootTest annotation like so:

@SpringBootTest(classes = { MyApplication.class, TestBeansConfiguration.class },
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

Unfortunately it seems that the BeansConfiguration class is still found and used because I get this error:

org.springframework.beans.factory.support.BeanDefinitionOverrideException: 
Invalid bean definition with name 'bean1' defined in com.example.TestBeansConfiguration:
Cannot register bean definition

[Root bean: class [null]; scope=; abstract=false;
lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=true;
factoryBeanName=testBeansConfiguration; factoryMethodName=bean1; initMethodName=null;
destroyMethodName=(inferred);

defined in com.example.TestBeansConfiguration] for bean 'bean1': There is already

[Root bean: class [null]; scope=; abstract=false;
lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false;
factoryBeanName=beansConfiguration; factoryMethodName=bean1; initMethodName=null;
destroyMethodName=(inferred);

defined in class path resource [com/example/BeansConfiguration.class]] bound.

(I've formatted the error message to make it easier to read)

I've also tried defining an ApplicationContextInitializer class to try to manually exclude the BeansConfiguration class, like so:

public class ConfigExcludingContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        applicationContext.getBeanFactory().registerResolvableDependency(BeansConfiguration.class, null);
    }
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = {SpringMainConfiguration.class, TestSpringConfiguration.class}, 
        initializers = ConfigExcludingContextInitializer.class)

But the result is the same.

I'd really like to leave the @Profile annotation as a last resource. Is there really no other easy way to exclude a specific @Configuration class from unit tests?

Thanks for any help.


Solution

  • You may have a misconception about @Primary. If multiple beans of the same type are defined, it does not mean that the one with @Primary will be created while the non-primary are ignored. @Primary is just useful for injection by type (e.g. @Autowired) which you have multiple beans of the same type defined but no @Qualifier are used to specify which bean to inject , then the @Primary one will be chosen to inject.

    The current exception BeanDefinitionOverrideException is because you define multiple beans with the same bean name. It is not allowed because bean name is required to be a unique identifier.

    You can configure spring.main.allow-bean-definition-overriding=true such that spring will not throw exception if it detects a bean with the same name is already defined before. Instead , it will override the previous one with the current one.

    Meaning that change to the following should solve your problem :

    @SpringBootTest(properties = {"spring.main.allow-bean-definition-overriding=true"}
            , classes = {MyApplication.class, TestBeansConfiguration.class})
    public class AppTest {
    
    
    }
    

    The bean processing order are based on the order of the context classes that are specified in @SpringBootTest .So beans defined due to the TestBeansConfiguration will override those due to MyApplication in case they have the same bean name.