Main question: Is there any way to replace a bean with a mock object in the whole context of Spring and inject the exact bean to the test to verify method calls?
I have a Spring Boot application, and I'm trying to write some integration tests in which I'm calling the Rest APIs using MockMvc
.
Integration tests run against an actual database and AWS resources using Testcontainer
and Localstack
. But for testing APIs which are integrated with Keycloak
as an external dependency, I decided to mock KeycloakService
and verify that the correct parameters are passed to the proper function of this class.
All of my integration test classes are a subclass of an abstract class called AbstractSpringIntegrationTest
:
@Transactional
@Testcontainers
@ActiveProfiles("it")
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@ContextConfiguration(initializers = PostgresITConfig.DockerPostgreDataSourceInitializer.class, classes = {AwsITConfig.class})
public class AbstractSpringIntegrationTest {
@Autowired
public MockMvc mockMvc;
@Autowired
public AmazonSQSAsync amazonSQS;
}
Consider there is a subclass like the following class:
class UserIntegrationTest extends AbstractSpringIntegrationTest {
private static final String USERS_BASE_URL = "/users";
@Autowired
private UserRepository userRepository;
@MockBean
private KeycloakService keycloakService;
@ParameterizedTest
@ValueSource(booleans = {true, false})
void changeUserStatus_shouldEnableOrDisableTheUser(boolean enabled) throws Exception {
// Some test setup here
ChangeUserStatusRequest request = new ChangeUserStatusRequest()
.setEnabled(enabled);
String responseString = mockMvc.perform(patch(USERS_BASE_URL + "/{id}/status", id)
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
// Some assertions here
Awaitility.await()
.atMost(10, SECONDS)
.untilAsserted(() -> verify(keycloakService, times(1)).changeUserStatus(email, enabled); // Fails to verify method call
}
}
And this is the class that calls functions of the KeycloakService
based on events:
@Slf4j
@Component
public class UserEventSQSListener {
private final KeycloakService keycloakService;
public UserEventSQSListener(KeycloakService keycloakService) {
this.keycloakService = keycloakService;
}
@SqsListener(value = "${cloud.aws.sqs.user-status-changed-queue}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void handleUserStatusChangedEvent(UserStatusChangedEvent event) {
keycloakService.changeUserStatus(event.getEmail(), event.isEnabled());
}
}
Whenever I run the test, I get the following error:
Wanted but not invoked:
keycloakService bean.changeUserStatus(
"[email protected]",
true
);
Actually, there were zero interactions with this mock.
After debugging the code, I understood that the bean mocked in UserIntegrationTest
is not the same bean injected into the UserEventSQSListener
class due to the context reloading. So, I tried other solutions like creating a mock object using Mockito.mock()
and return it as a bean, and using @MockInBean, but they didn't work as well.
@TestConfiguration
public static class TestBeanConfig {
@Bean
@Primary
public KeycloakService keycloakService() {
KeycloakService keycloakService = Mockito.mock(KeycloakService.class);
return keycloakService;
}
}
Update 1:
Based on @Maziz's answer and for debug purposes I change the code like the following:
@Component
public class UserEventSQSListener {
private final KeycloakService keycloakService;
public UserEventSQSListener(KeycloakService keycloakService) {
this.keycloakService = keycloakService;
}
public KeycloakService getKeycloakService() {
return keycloakService;
}
...
class UserIT extends AbstractSpringIntegrationTest {
...
@Autowired
private UserEventSQSListener userEventSQSListener;
@Autowired
private Map<String, UserEventSQSListener> beans;
private KeycloakService keycloakService;
@BeforeEach
void setup() {
...
keycloakService = mock(KeycloakService.class);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void changeUserStatus_shouldEnableOrDisableTheUser(boolean enabled) throws Exception {
// Some test setup here
ChangeUserStatusRequest request = new ChangeUserStatusRequest()
.setEnabled(enabled);
String responseString = mockMvc.perform(patch(USERS_BASE_URL + "/{id}/status", id)
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
// Some assertions here
ReflectionTestUtils.setField(userEventSQSListener, "keycloakService", keycloakService);
assertThat(userEventSQSListener.getKeycloakService()).isEqualTo(keycloakService);
await().atMost(10, SECONDS)
.untilAsserted(() -> verify(keycloakService).changeUserStatus(anyString(), anyBoolean())); // Fails to verify method call
}
As you see the mock is appropriately replaced inside the UserEventSQSListener
class:
Still, I got the following error:
Wanted but not invoked:
keycloakService.changeUserStatus(
<any string>,
<any boolean>
);
Actually, there were zero interactions with this mock.
Integration testing is a must if you want stable software. However, it's difficult to get your integration tests to call the real dependencies, especially when it comes to load tests. That's where API mocking can help. This article will explain you how to do that with maximizing the power of Azure!
Even if Mockito (see my previous post) usually helps us to cut all dependencies in our unit tests, it can be useful also in the integration tests where we often want to have all real applications layers.
@SpringBootTest It starts the embedded server, creates a web environment and then enables @Test methods to do integration testing. By default, @SpringBootTest does not start a server. We need to add attribute webEnvironment to further refine how your tests run.
If different tests need different configurations, Spring Boot cannot cache the application context and loads a new context with that configuration. So whenever we use @MockBean, @ActiveProfiles, @DynamicPropertySource or any other annotations that customize the configuration, Spring creates a new application context for the tests.
So whenever we use @MockBean, @ActiveProfiles, @DynamicPropertySource or any other annotations that customize the configuration, Spring creates a new application context for the tests. A common mistake with Spring Boot integration tests is to start every test with @SpringBootTest and then try to configure each test for a specific case.
This way, we can first start a MockWebServer instance in the test and tell the server URL to Spring Boot via DynamicPropertyRegistry. Now we can use the MockWebServer in our tests: It’s good to understand that we are only communicating with the server via REST calls through the HTTP connection. So we are looking at the application from the outside.
However, a crucial difference here is that @MockMvcTest only configures a part of the application context while @SpringBootTest loads the entire one. Spring Boot has several auto configurations that configure smaller parts of the context. Here we are using @AutoConfigureMockMvc that is not included in @SpringBootTest but is part of @WebMvcTest.
Based on the answer from Maziz, shouldn't the line setField be before the mvc call?
@ParameterizedTest
@ValueSource(booleans = {true, false})
void changeUserStatus_shouldEnableOrDisableTheUser(boolean enabled) throws Exception {
// Some test setup here
ChangeUserStatusRequest request = new ChangeUserStatusRequest()
.setEnabled(enabled);
ReflectionTestUtils.setField(userEventSQSListener, "keycloakService", keycloakService);
String responseString = mockMvc.perform(patch(USERS_BASE_URL + "/{id}/status", id)
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
// Some assertions here
assertThat(userEventSQSListener.getKeycloakService()).isEqualTo(keycloakService);
await().atMost(10, SECONDS)
.untilAsserted(() -> verify(keycloakService).changeUserStatus(anyString(), anyBoolean())); // Fails to verify method call
}
and if this still does not work you can replace that line with
org.powermock.reflect.Whitebox.setInternalState(UserEventSQSListener.class, "keycloakService", keycloakService);
but the general idea remains the same.
Did you debug the KeyClockService in the UserEventSQSListener? Did you see if the object is type proxy something indicating a mock object?
Regardless of the answer, before you call mockMvc.perform, can use
ReflectionTestUtils.setField(UserEventSQSListener, "keycloakService", keycloakService /*the mock object*/)
Run again. Let me know if it's ok or not.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With