When setting up @SpringBootTest as follows:

@SpringBootTest(classes = [MyTestComponent::class])
@TestExecutionListeners(value = [MyTestExecutionListener::class], mergeMode = MERGE_WITH_DEFAULTS)
@TestConstructor(autowireMode = ALL)
class TestExecutionListenersNestedApplicationTests(private val testComponent: MyTestComponent) {
  // ...
  @TestComponent
  class MyTestComponent(var enabled: Boolean = false)

  class MyTestExecutionListener : TestExecutionListener {

    override fun beforeTestMethod(testContext: TestContext) {
      testContext.applicationContext.getBean(MyTestComponent::class.java).enabled = true
    }

    override fun afterTestMethod(testContext: TestContext) {
      testContext.applicationContext.getBean(MyTestComponent::class.java).enabled = false
    }
  }
  // ...
}  

I would expect both of these @Nested test classes to behave the same way:

  @Nested
  inner class ClassWithSameContext {

    @Test
    fun noProblemo() {
      assertThat(testComponent.enabled).isTrue
    }
  }

  @Nested
  @TestPropertySource(properties = ["test=true"])
  inner class ClassWithDifferentContext {

    @Test
    fun problemo() {
      assertThat(testComponent.enabled).isTrue
    }
  }

However, the test in the first one passes and the test in the second one fails.

This looks like a bug to me, or am I missing something?

Stepping through the test with a debugger shows that a context gets processed in the beforeTestMethod() method, but that context does not seem to be used in the test.

See here for a runnable sample project.

Comment From: wilkinsona

Thanks for the report and sample. This problem doesn't have anything to do with Spring Boot itself as it can be reproduced using a pure Spring Framework based test:

package io.github.vootelerotov.testexecutionlistenersnested

import io.github.vootelerotov.testexecutionlistenersnested.TestExecutionListenersNestedApplicationTests.MyTestComponent
import io.github.vootelerotov.testexecutionlistenersnested.TestExecutionListenersNestedApplicationTests.MyTestExecutionListener
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.stereotype.Component
import org.springframework.test.context.TestConstructor
import org.springframework.test.context.TestConstructor.AutowireMode.ALL
import org.springframework.test.context.TestContext
import org.springframework.test.context.TestExecutionListener
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig

@SpringJUnitConfig(classes = [MyTestComponent::class])
@TestExecutionListeners(value = [MyTestExecutionListener::class], mergeMode = MERGE_WITH_DEFAULTS)
@TestConstructor(autowireMode = ALL)
class TestExecutionListenersNestedApplicationTests(private val testComponent: MyTestComponent) {

  @Test
  fun worksWithoutNestedClassWithNewContext() {
    assertThat(testComponent.enabled).isTrue
  }

  @Nested
  inner class ClassWithSameContext {

    @Test
    fun noProblemo() {
      assertThat(testComponent.enabled).isTrue
    }
  }

  @Nested
  @TestPropertySource(properties = ["test=true"])
  inner class ClassWithDifferentContext {

    @Test
    fun problemo() {
      assertThat(testComponent.enabled).isTrue
    }
  }

  @Component
  class MyTestComponent(var enabled: Boolean = false)

  class MyTestExecutionListener : TestExecutionListener {

    override fun beforeTestMethod(testContext: TestContext) {
      testContext.applicationContext.getBean(MyTestComponent::class.java).enabled = true
    }

    override fun afterTestMethod(testContext: TestContext) {
      testContext.applicationContext.getBean(MyTestComponent::class.java).enabled = false
    }
  }

}

We'll transfer the issue so that the Framework team can take a look.

Comment From: sbrannen

This looks like a bug to me, or am I missing something?

This is not a bug. It is the expected behavior.

The enclosing test class instance is injected with beans from its own ApplicationContext, not with beans from the ApplicationContext of a nested test class or subclass. The same holds true for any test class.

Similarly, a TestExecutionListener is provided access to the ApplicationContext for the test class with which the listener is associated.

Thus, when the MyTestExecutionListener is invoked for ClassWithDifferentContext, testContext.getApplicationContext().getBean(MyTestComponent.class) accesses the MyTestComponent bean in the ApplicationContext for ClassWithDifferentContext.

But... the testComponent field/property is injected from the ApplicationContext for TestExecutionListenersNestedApplicationTests.

So your test method and your listener are interacting with different instances of MyTestComponent from different application contexts.

The following modified (and converted to Java) version of your example demonstrates this.

@SpringJUnitConfig(MyTestComponent.class)
@TestExecutionListeners(listeners = MyTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS)
@TestConstructor(autowireMode = ALL)
class TestExecutionListenersNestedApplicationTests {

    private final MyTestComponent testComponent;

    TestExecutionListenersNestedApplicationTests(MyTestComponent testComponent) {
        this.testComponent = testComponent;
    }

    @Test
    void worksWithoutNestedClassWithNewContext() {
        assertThat(testComponent.enabled).isTrue();
    }

    @Nested
    class ClassWithSameContext {
        @Test
        void noProblemo() {
            assertThat(testComponent.enabled).isTrue();
        }
    }

    @Nested
    @TestPropertySource(properties = "test=true")
    class ClassWithDifferentContext {

        private final MyTestComponent localTestComponent;

        ClassWithDifferentContext(MyTestComponent testComponent) {
            this.localTestComponent = testComponent;
        }

        @Test
        void problemo() {
            assertThat(testComponent.enabled).isFalse();
            assertThat(localTestComponent.enabled).isTrue();
        }
    }

    @Component
    static class MyTestComponent {

        private boolean enabled;

        boolean isEnabled() {
            return enabled;
        }

        void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }
    }

    static class MyTestExecutionListener implements TestExecutionListener {
        @Override
        public void beforeTestMethod(TestContext testContext) {
            testContext.getApplicationContext().getBean(MyTestComponent.class).setEnabled(true);
        }

        @Override
        public void afterTestMethod(TestContext testContext) {
            testContext.getApplicationContext().getBean(MyTestComponent.class).setEnabled(false);
        }
    }

}

Specifically, the problemo() test method asserts that the local MyTestComponent did in fact have its enabled flag set to true by the TestExecutionListener.


The only way to access the ApplicationContext of a different test class from a Spring TestExecutionListener is by creating a new TestContextManager for the specific class. The following demonstrates how you could interact with the context for the enclosing class, which would make your original test pass.

@Override
public void beforeTestMethod(TestContext testContext) {
    new TestContextManager(TestExecutionListenersNestedApplicationTests.class)
        .getTestContext().getApplicationContext().getBean(MyTestComponent.class).setEnabled(true);
}

However, if you choose to implement a JUnit Jupiter extension instead of a Spring TestExecutionListener you can come up with a cleaner (though admittedly more involved) solution. If you completely remove your custom TestExecutionListener and add the following BeforeEachCallback and AfterEachCallback extensions to TestExecutionListenersNestedApplicationTests, you'll see that your original tests pass.

@RegisterExtension
BeforeEachCallback enableComponentExtension = context ->
    getApplicationContextForTopLevelTestClass(context).getBean(MyTestComponent.class).setEnabled(true);

@RegisterExtension
AfterEachCallback disableComponentExtension = context ->
    getApplicationContextForTopLevelTestClass(context).getBean(MyTestComponent.class).setEnabled(false);

There's no built-in support for getApplicationContextForTopLevelTestClass(), but the following proof of concept (which should be simplified) demonstrates that it's possible.

private static ApplicationContext getApplicationContextForTopLevelTestClass(ExtensionContext context) {
    List<Object> enclosingInstances = context.getRequiredTestInstances().getEnclosingInstances();
    if (enclosingInstances.isEmpty()) {
        return SpringExtension.getApplicationContext(context);
    }
    else {
        Class<?> topLevelTestClass = enclosingInstances.get(0).getClass();
        ExtensionContext parent = context.getParent().orElse(null);
        while (parent != null) {
            if (parent.getRequiredTestClass().equals(topLevelTestClass)) {
                break;
            }
            parent = parent.getParent().orElse(null);
        }
        return SpringExtension.getApplicationContext(parent);
    }
}

In light of the above, I am closing this issue.

Though if you have any further questions, feel free to ask.

Comment From: vootelerotov

Thus, when the MyTestExecutionListener is invoked for ClassWithDifferentContext, testContext.getApplicationContext().getBean(MyTestComponent.class) accesses the MyTestComponent bean in the ApplicationContext for ClassWithDifferentContext.

But... the testComponent field/property is injected from the ApplicationContext for TestExecutionListenersNestedApplicationTests.

This seems to be what I missed in my mental model. Makes sense.

@sbrannen, thanks for taking the time to give a very thorough answer.

Comment From: sbrannen

@sbrannen, thanks for taking the time to give a very thorough answer.

You're welcome.

The reason it's so "thorough" is that your use case sparked some thought experiments in my head, and I wanted to verify what options actually exist to support such use cases.