@SpringBootTest: @MockBean not injected when multiple test classes

1.9k Views Asked by At

I want to write controller tests that also test my annotations. What I've read so far is that RestAssured one of the ways to go.

It works smoothly when I only have one controller test in place. However, when having 2 or more controller test classes in place, the @MockBeans seem to not be used properly. Depending on the test execution order, all tests from the first test class succeed, and all others fail.

In the following test run, the PotatoControllerTest was executed first, and then the FooControllerTest.

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"test", "httptest"})
class FooControllerTest {

    @MockBean
    protected FooService mockFooService;

    @MockBean
    protected BarService mockBarService;

    @LocalServerPort
    protected int port;

    @BeforeEach
    public void setup() {
        RestAssured.port = port;
        RestAssured.authentication = basic(TestSecurityConfiguration.ADMIN_USERNAME, TestSecurityConfiguration.ADMIN_PASSWORD);
        RestAssured.requestSpecification = new RequestSpecBuilder()
                .setContentType(ContentType.JSON)
                .setAccept(ContentType.JSON)
                .build();
    }

    @SneakyThrows
    @Test
    void deleteFooNotExists() {
        final Foo foo = TestUtils.generateTestFoo();
        Mockito.doThrow(new DoesNotExistException("missing")).when(mockFooService).delete(foo.getId(), foo.getVersion());

        RestAssured.given()
                .when().delete("/v1/foo/{id}/{version}", foo.getId(), foo.getVersion())
                .then()
                .statusCode(HttpStatus.NOT_FOUND.value());
        Mockito.verify(mockFooService, times(1)).delete(foo.getId(), foo.getVersion());
    }

...
}
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"test", "httptest"})
class PotatoControllerTest {

    @MockBean
    protected PotatoService mockPotatoService;

    @LocalServerPort
    protected int port;

    @BeforeEach
    public void setup() {
        RestAssured.port = port;
        RestAssured.authentication = basic(TestSecurityConfiguration.ADMIN_USERNAME, TestSecurityConfiguration.ADMIN_PASSWORD);
        RestAssured.requestSpecification = new RequestSpecBuilder()
                .setContentType(ContentType.JSON)
                .setAccept(ContentType.JSON)
                .build();
    }

...

}
Wanted but not invoked:
fooService bean.delete(
    "10e76ae4-ec1b-49ce-b162-8a5c587de2a8",
    "06db13f1-c4cd-435d-9693-b94c26503d40"
);
-> at com.xxx.service.FooService.delete(FooService.java:197)
Actually, there were zero interactions with this mock.

I tried to fix it with a common ControllerTestBase which configures all mocks and all other controller tests extending the base class. Which worked fine on my machine, but e.g. not in the pipeline. So I guess it is not really stable.

Why is Spring not reloading the context with the mocks? Is this the "best" way of testing my controllers?

2

There are 2 best solutions below

2
On BEST ANSWER

It would be much easier and way faster to just use MockMvc.

You can just create a standalone setup for your desired controller and do additional configuration (like setting exception resolvers). Also you're able to inject your mocks easily:

@Before
public void init() {
  MyController myController = new MyController(mock1, mock2, ...);
  MockMvc mockMvc = 
  MockMvcBuilders.standaloneSetup(myController)
                 .setHandlerExceptionResolvers(...)
                 .build();
}

Afterwards you can easily call your endpoints:

MvcResult result = mockMvc.perform(
            get("/someApi"))
             .andExpect(status().isOk)
             .andReturn();

Additional validation on the response can be done like you already know it.

Edit: As a side note - this is designed to explicitly test your web layer. If you want to go for some kind of integration test going further down in your application stack, also covering business logic, this is not the right approach.

0
On

Since there was no answer given to "Why is Spring not reloading the context with the mocks?" question and I ran into it myself, here is my understanding of why it is the case.

The reason why Spring doesn't reload the context in case there are multiple tests using Spring's ApplicationContext, e.g., multiple @SpringBootTest tests is Context Caching:

Once the TestContext framework loads an ApplicationContext (or WebApplicationContext) for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite.

An ApplicationContext can be uniquely identified by the combination of configuration parameters that is used to load it. Consequently, the unique combination of configuration parameters is used to generate a key under which the context is cached.

And in your case the key was the same for both cases. Therefore, even though @MockBean protected FooService mockFooService is correctly injected in FooControllerTest, it won't be injected in the controller under the test - the controller will get an instance of FooService from the ApplicationContext of PotatoControllerTest, which will have an actual instance of the service injected.

Option 1

As the documentation suggests, you can work around this behaviour by configuring different context keys for different test classes by either using different profiles or different ContextConfigurations for different tests.

@ContextConfiguration(classes = MockedServiceConfiguration.class)
class FooControllerTest {
  @Autowired
  FooService fooService; // this is a mocked bean that's injected in FooController and FooControllerTest
}

@TestConfiguration
class MockedServiceConfiguration {
  @MockBean
  FooService mockedService;
}


@ContextConfiguration(classes = OtherServiceConfiguration.class)
class BarControllerTest {
  
}


@TestConfiguration
class OtherServiceConfiguration {
  @Bean
  public FooService getService() {
   return new FooService(); // can probably also be @Autowired from the Spring context if it's declared as @Component or @Service
  }
}

Option 2

If you can get away with a mocked service in both tests, you can also avoid using @MockBean altogether and define a regular @Bean which is also a mock:

@SpringBootTest(classes = FooTestConfiguration.class)
class FooControllerTest {
  @Autowired
  FooService fooService; // Mockito#verify should work as expected
}

@SpringBootTest(classes = FooTestConfiguration.class)
class BarControllerTest {

}

@TestConfiguration
class FooTestConfiguration {
  @Bean
  public FooService getService() {
    return Mockito.mock(FooService.class);
  }
}

Option 3

Another alternative could be to use reflection to set FooController's fooService field to reference the mocked bean before executing the test, e.g., in @BeforeAll method.