spring-projects / spring-projects/spring-framework

BeanOverrideHandler fails when a single mock is declared via multiple meta-annotation paths

Open
#35,231 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

in: test status: waiting-for-triage
Dominant language
Java
Stars
60.2k
Forks
38.8k
Avg merge
5d 2h
Merged PRs (30d)
27

Description

When the same mock declaration (via @MockitoBean) is indirectly referenced through multiple meta-annotation paths on a test class, Spring's BeanOverrideContextCustomizerFactory reports a Duplicate BeanOverrideHandler exception.

The framework treats the two paths to the same mock declaration as distinct, even though it should result in a single mock instance.

To Reproduce:

package org.springframework.test.context.bean.override;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;

import java.lang.annotation.Documented;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@SuppressWarnings("javadoc")
public class BeanOverrideHandlerBug {

    @Target(TYPE)
    @Retention(RUNTIME)
    @Documented
    @Inherited
    @MockitoBean(types = MockedService.class)
    @ImportAutoConfiguration({ ServiceThatUsesMockedServiceAutoConfiguration.class })
    public @interface WithServiceThatUsesMockedService {}

    @Target(TYPE)
    @Retention(RUNTIME)
    @Documented
    @Inherited
    @WithServiceThatUsesMockedService
    @ImportAutoConfiguration({ ServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration.class })
    public @interface WithServiceThatUsesTheServiceThatUsesMockedService {}

    @Target(TYPE)
    @Retention(RUNTIME)
    @Documented
    @Inherited
    //Having two paths to the same @MockitoBean causes failures
    @WithServiceThatUsesMockedService
    @WithServiceThatUsesTheServiceThatUsesMockedService
    @ImportAutoConfiguration({ AnotherServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration.class })
    public @interface WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceBad {}

    @Target(TYPE)
    @Retention(RUNTIME)
    @Documented
    @Inherited
    @WithServiceThatUsesTheServiceThatUsesMockedService
    @ImportAutoConfiguration({ AnotherServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration.class })
    public @interface WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceGood {}

    public static class MockedService {}

    public static class ServiceThatUsesMockedService {

        private final MockedService mockedService;

        public ServiceThatUsesMockedService(final MockedService mockedService) {
            this.mockedService = mockedService;
        }

        public MockedService getMockedService() {
            return this.mockedService;
        }

    }

    public static class ServiceThatUsesTheServiceThatUsesMockedService {

        private final ServiceThatUsesMockedService serviceThatUsesMockedService;

        public ServiceThatUsesTheServiceThatUsesMockedService(
                final ServiceThatUsesMockedService serviceThatUsesMockedService) {
            this.serviceThatUsesMockedService = serviceThatUsesMockedService;
        }

        public ServiceThatUsesMockedService getServiceThatUsesMockedService() {
            return this.serviceThatUsesMockedService;
        }

    }

    public static class AnotherServiceThatUsesTheServiceThatUsesMockedService {

        private final ServiceThatUsesMockedService serviceThatUsesMockedService;

        private final ServiceThatUsesTheServiceThatUsesMockedService serviceThatUsesTheServiceThatUsesMockedService;

        public AnotherServiceThatUsesTheServiceThatUsesMockedService(
                final ServiceThatUsesMockedService serviceThatUsesMockedService,
                final ServiceThatUsesTheServiceThatUsesMockedService serviceThatUsesTheServiceThatUsesMockedService) {

            this.serviceThatUsesMockedService = serviceThatUsesMockedService;

            this.serviceThatUsesTheServiceThatUsesMockedService = serviceThatUsesTheServiceThatUsesMockedService;
        }

        public ServiceThatUsesMockedService getServiceThatUsesMockedService() {
            return this.serviceThatUsesMockedService;
        }

        public ServiceThatUsesTheServiceThatUsesMockedService getServiceThatUsesTheServiceThatUsesMockedService() {
            return this.serviceThatUsesTheServiceThatUsesMockedService;
        }
    }

    @Configuration
    public static class ServiceThatUsesMockedServiceAutoConfiguration {

        private final MockedService mockedService;

        public ServiceThatUsesMockedServiceAutoConfiguration(final MockedService mockedService) {
            this.mockedService = mockedService;
        }

        @Bean
        public ServiceThatUsesMockedService serviceThatUsesMockedService() {
            return new ServiceThatUsesMockedService(this.mockedService);
        }
    }

    @Configuration
    public static class ServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration {

        private final ServiceThatUsesMockedService serviceThatUsesMockedService;

        public ServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration(
                final ServiceThatUsesMockedService serviceThatUsesMockedService) {
            this.serviceThatUsesMockedService = serviceThatUsesMockedService;
        }

        @Bean
        public ServiceThatUsesTheServiceThatUsesMockedService serviceThatUsesTheServiceThatUsesMockedService() {
            return new ServiceThatUsesTheServiceThatUsesMockedService(this.serviceThatUsesMockedService);
        }
    }

    @Configuration
    public static class AnotherServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration {

        private final ServiceThatUsesMockedService serviceThatUsesMockedService;

        private final ServiceThatUsesTheServiceThatUsesMockedService serviceThatUsesTheServiceThatUsesMockedService;

        public AnotherServiceThatUsesTheServiceThatUsesMockedServiceAutoConfiguration(
                final ServiceThatUsesMockedService serviceThatUsesMockedService,
                final ServiceThatUsesTheServiceThatUsesMockedService serviceThatUsesTheServiceThatUsesMockedService) {

            this.serviceThatUsesMockedService = serviceThatUsesMockedService;

            this.serviceThatUsesTheServiceThatUsesMockedService = serviceThatUsesTheServiceThatUsesMockedService;
        }

        @Bean
        public AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService() {
            return new AnotherServiceThatUsesTheServiceThatUsesMockedService(this.serviceThatUsesMockedService,
                    this.serviceThatUsesTheServiceThatUsesMockedService);
        }
    }

}
package org.springframework.test.context.bean.override;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.context.TestConstructor.AutowireMode.*;

import org.junit.jupiter.api.Test;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.context.bean.override.BeanOverrideHandlerBug.AnotherServiceThatUsesTheServiceThatUsesMockedService;
import org.springframework.test.context.bean.override.BeanOverrideHandlerBug.WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceBad;
import org.springframework.test.context.bean.override.BeanOverrideHandlerBug.WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceGood;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

@SuppressWarnings("javadoc")
public class BeanOverrideHandlerBugTest {

    static void assertService(
            final AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService) {
        assertNotNull(anotherServiceThatUsesTheServiceThatUsesMockedService);

        assertNotNull(anotherServiceThatUsesTheServiceThatUsesMockedService.getServiceThatUsesMockedService());

        assertNotNull(anotherServiceThatUsesTheServiceThatUsesMockedService.getServiceThatUsesMockedService()
                .getMockedService());

        assertNotNull(
            anotherServiceThatUsesTheServiceThatUsesMockedService.getServiceThatUsesTheServiceThatUsesMockedService());

        assertNotNull(
            anotherServiceThatUsesTheServiceThatUsesMockedService.getServiceThatUsesTheServiceThatUsesMockedService()
                    .getServiceThatUsesMockedService());
        assertNotNull(
            anotherServiceThatUsesTheServiceThatUsesMockedService.getServiceThatUsesTheServiceThatUsesMockedService()
                    .getServiceThatUsesMockedService()
                    .getMockedService());
    }

    @SpringJUnitConfig
    @TestConstructor(autowireMode = ALL)
    @WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceBad
    static class BeanOverrideHandlerBugTestFails {

        private final AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService;

        public BeanOverrideHandlerBugTestFail(
                final AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService) {
            this.anotherServiceThatUsesTheServiceThatUsesMockedService
                    = anotherServiceThatUsesTheServiceThatUsesMockedService;
        }

        /*
         * Fails with java.lang.IllegalStateException: Duplicate BeanOverrideHandler discovered in test class
         * org.springframework.test.context.bean.override.BeanOverrideHandlerBugTest$BeanOverrideHandlerBugTestFail:
         * [MockitoBeanOverrideHandler@4650a407 field = [null], beanType =
         * org.springframework.test.context.bean.override.BeanOverrideHandlerBug$MockedService, beanName = [null],
         * contextName = '', strategy = REPLACE_OR_CREATE, reset = AFTER, extraInterfaces = set[[empty]], answers =
         * RETURNS_DEFAULTS, serializable = false]
         * 
         */
        @Test
        void test() {
            assertService(this.anotherServiceThatUsesTheServiceThatUsesMockedService);

        }
    }

    @SpringJUnitConfig
    @TestConstructor(autowireMode = ALL)
    @WithAnotherServiceThatUsesTheServiceThatUsesMockedServiceGood
    static class BeanOverrideHandlerBugTestPasses {

        private final AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService;

        public BeanOverrideHandlerBugTestPass(
                final AnotherServiceThatUsesTheServiceThatUsesMockedService anotherServiceThatUsesTheServiceThatUsesMockedService) {
            this.anotherServiceThatUsesTheServiceThatUsesMockedService
                    = anotherServiceThatUsesTheServiceThatUsesMockedService;
        }

        @Test
        void test() {
            assertService(this.anotherServiceThatUsesTheServiceThatUsesMockedService);
        }
    }
}

The error:

java.lang.IllegalStateException: Duplicate BeanOverrideHandler discovered in test class org.springframework.test.context.bean.override.BeanOverrideHandlerBugTest$BeanOverrideHandlerBugTestFail: [MockitoBeanOverrideHandler@4650a407 field = [null], beanType = org.springframework.test.context.bean.override.BeanOverrideHandlerBug$MockedService, beanName = [null], contextName = '', strategy = REPLACE_OR_CREATE, reset = AFTER, extraInterfaces = set[[empty]], answers = RETURNS_DEFAULTS, serializable = false]
	at org.springframework.util.Assert.state(Assert.java:101)
	at org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory.lambda$findBeanOverrideHandlers$2(BeanOverrideContextCustomizerFactory.java:61)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
	at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:179)
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1625)
	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:150)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory.findBeanOverrideHandlers(BeanOverrideContextCustomizerFactory.java:61)
	at org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory.createContextCustomizer(BeanOverrideContextCustomizerFactory.java:49)
	at org.springframework.test.context.bean.override.BeanOverrideContextCustomizerFactory.createContextCustomizer(BeanOverrideContextCustomizerFactory.java:38)
	at org.springframework.test.context.support.AbstractTestContextBootstrapper.getContextCustomizers(AbstractTestContextBootstrapper.java:360)
	at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:332)
	at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:244)
	at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:108)
	at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:142)
	at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:126)
	at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:362)
	at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:128)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

Spring Framework 6.2.9
Spring Boot 3.5.3

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at BeanOverrideContextCustomizerFactory.findBeanOverrideHandlers and use the BeanOverrideHandlerBugTest reproduction to trace how the same MockitoBean is discovered through multiple meta-annotation paths. Done means the duplicate handler exception no longer occurs and the supplied assertions pass while the single mock is shared by the dependent services.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring
Domain
testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.