Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mocking external dependencies in Spring Boot integration tests

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:

debug the injection

Still, I got the following error:

Wanted but not invoked:
keycloakService.changeUserStatus(
    <any string>,
    <any boolean>
);
Actually, there were zero interactions with this mock.
like image 327
Reza Ebrahimpour Avatar asked Oct 14 '21 16:10

Reza Ebrahimpour


People also ask

Can integration test have mocks?

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!

Can we use Mockito in integration test?

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.

Is SpringBootTest integration test?

@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.

Why does Spring Boot create a new context for each test?

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.

Why does @mockbean create a new application context for every test?

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.

How to use mockwebserver in a Spring Boot test?

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.

What is the difference between @mockmvctest and @springboottest?

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.


2 Answers

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.

like image 183
Parth Manaktala Avatar answered Oct 17 '22 23:10

Parth Manaktala


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.

like image 35
Maziz Avatar answered Oct 17 '22 23:10

Maziz