Using @MockBean in tests forces reloading of Application Context
Asked Answered
L

4

25

I have several integration tests running on Spring Framework that extend the base class called BaseITCase.
Like this:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {AppCacheConfiguration.class, TestConfiguration.class}, loader = SpringBootContextLoader.class)
@Transactional
@WebMvcTest
public abstract class BaseITCase{...}
...
public class UserControllerTest extends BaseITCase {...}

The problem is that one of the test has several declarations of: @MockBean inside of it and the moment this test executed, Spring recreates context and the tests that follows this one sometimes use wrong beans(from the context created exactly for the test with @MockBean). I found out about that just by checking that beans have different hashcodes.

It becomes really critical when I use @EventListener. Because listeners for wrong context(context of the test class that has already finished execution) are invoked and I have wrong beans there.

Is there any workaround for that?

I tried to move all @MockBean declarations to basic class and it worked fine because new context is not created. But, it makes basic class too heavy. Also, I tried to make a dirty context for this test, but then the next test fails with message that context has already been closed.

Litman answered 9/8, 2017 at 9:49 Comment(1)
By mistake a posted an answer for another question in your post. Sorry! I deleted it.Consignee
F
26

The reason is that the spring configuration for the test having the @MockBean is different from the rest of the tests, so the spring framework cannot cache previously used context and needs to load it again. Here you can find a more detailed explanation: https://github.com/spring-projects/spring-boot/issues/10015

As you said, if you move the mock bean to the parent class the context doesn't get reloaded, which makes sense as the bean configuration remains the same.

A possible workaround is defining your mock bean as a simple mock and injecting it manually where is required.

For instance, UserController has a dependency on Foo:

public class UserControllerTest extends BaseITCase {

    private Foo foo = Mockito.mock(Foo.class);

    @Autowired
    private UserController userController;

    @Before
    public void setUp() {
        super.setup();

        this.userController.setFoo(foo);
    }
}

@Component
public class UserController {

    private Foo foo;

    @Autowired
    public void setFoo(final Foo foo) {
        this.foo = foo;
    }
}

Hope this helps.

Frigate answered 26/2, 2018 at 11:57 Comment(1)
I wouldn't recommend this approach since it "dirties" the application context. Your implementation above will likely break any tests which run after this one which rely on Foo. At bare minimum you should store the initial Foo in @Before then restore it back to it's original state in @ After.Nader
P
8

@MockBean may cause the context to reload as explained in the previous answer.

As an alternative and if you're using spring boot 2.2+, you can use @MockInBean instead of @MockBean. It keeps your context clean and does not require your context to get reloaded.

@SpringBootTest
public class UserControllerTest extends BaseITCase {

    @MockInBean(UserController.class)
    private Foo foo;

    @Autowired
    private UserController userController;

    @Test
    public void test() {
        userController.doSomething();
        Mockito.verify(foo).hasDoneSomething();
    }
}

@Component
public class UserController {

    @Autowired
    private Foo foo;

}

disclaimer: I created this lib for this exact purpose: mock beans in spring beans and avoid lengthy context recreation.

Paule answered 18/3, 2021 at 0:35 Comment(1)
Thanks @antoine-meyer for this project, it worked for me. The only thing that I'll add is that if you want spy on UserController, you can add @SpyBean private UserController userController in the BaseITCase and it'll work tooBlakeslee
E
1

Besides the above solutions, if you want to inject them everywhere, you can

  1. Create a configuration in your test packages and define the mock beans as @Primary so they'll be injected instead of the real ones.

    @Configuration
     public class MockClientConfiguration {
    
       @Bean
       @Primary
       public ApiClient mockApiClient() {
         return mock(ApiClient.class);
       }
    
  2. In your base test class @Autowire, since they are @Primary, you'll get the mocks. Notice, they are protected

    @SpringBootTest public class BaseIntTest {

       @Autowired
       protected ApiClient mockApiClient;
    
  3. Then in your base test class you can reset the mocks before each run and set default behaviour:

    @BeforeEach public void setup() { Mockito.reset(mockApiClient); Mockito.when(mockApiClient.something(USER_ID)).thenReturn(true); }

  4. From you test classes access the mocks:

    public class MyTest extends BaseIntTest {
       @Test
       public void importantTestCase() {
           Mockito.reset(mockApiClient);
           Mockito.when(mockApiClient.something(USER_ID)).thenReturn(false);
    
Ettaettari answered 25/5, 2023 at 11:10 Comment(0)
P
1

I ran into this and I think instead of using @Primary, the better solution is to use Spring's "Bean-Overriding" feature.

(This works specially well if you are using an actual web server when running the tests i.e. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT), which is the case for me):

Do the following:

  1. Create a separate application properties file for unit tests and annotate your unit test class as follows to load it:

    @TestPropertySource(locations = "classpath:application-unittest.properties")

  2. Set this property to true in the above application-unittest.properties file:

    spring.main.allow-bean-definition-overriding=true

  3. Make sure you dont have any annotations that modify the context after it is created (such as @AutoConfigureJsonTesters). Otherwise the context will get recreated after each test ((or throw IllegalStateException, depending on the whether your @TestInstance setting is TestInstance.Lifecycle.PER_METHOD or PER_CLASS). I dont know about a full list of such annotations, u can do trial and error to find out.

  4. Remove all @MockBean annotations and define all such beans inside an @Configuration class for your unit test class for example:

    @TestConfiguration public class UnitTestConfiguration {

    @Bean public MyService myService() { return Mockito.mock(MockService.class, MockReset.withSettings(MockReset.NONE)); }

    }

HERE YOU NEED TO MAKE SURE THE NAME OF THE METHOD (myService in this case) MATCHES THE NAME OF THE REAL BEAN when its created in the Spring context. This is necessary to use bean overriding.

So for example if you have autowired a real bean then by default Spring uses the class name of the bean in camel case as the bean name when defining it in the Spring-Context. So to override such a bean your method name should be the same as the name of the class but in camel case.

  1. Do an @Import in your unit test class to import the above configuration:

    @Import(UnitTestConfiguration.class)

Now you can just @Autowire the mocked beans inside your unit test class and apply stubbing (Mockito.when etc) as needed.

Practitioner answered 22/1 at 0:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.