6

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(
    "rodolfo.kautzer@example.com",
    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.
Reza Ebrahimpour
  • 814
  • 6
  • 20
  • You're sure that the LocalStack SQS setup for your integration test is working and the event arrives at your listener? – rieckpil Oct 16 '21 at 08:19
  • Yes. I do. At first, I thought the same, but If I inject the real `KeycloakService`s bean, the changes will be applied in the Keycloak side. – Reza Ebrahimpour Oct 16 '21 at 18:18
  • If the keycloakService mocked bean is not being injected, is the actual bean being initialised and calling the actual service? – Parth Manaktala Oct 17 '21 at 23:27
  • I did not read the entire question, but `@TestConfiguration` with the override option will not work for you? – Eugene Oct 18 '21 at 01:35
  • Since I've tested different solutions, I missed the track. Still, in some cases, the actual bean is initialized and injected to `UserEventSQSListener`, so the changes are applied to the Keycloak. In some cases, the `UserEventSQSListener` will be initialized again, and a new mocked object will be injected into it using the constructor, but the reference in the test class will not change. So another mock object will be called and the verification will happen on another mocked object and the error will be thrown. @ParthManaktala – Reza Ebrahimpour Oct 18 '21 at 06:18
  • "Actually, there were zero interactions with this mock.": The only(^^) explanations are: Mock object is not "properly wired" (which we can exclude at this point(!?)); or: Something "in between `UserEventSQSListener/@SqsListener` and mock object" is not properly wired/ **mocked (away, silently)/not invoked in the test environment/runtime..** ; I think "parameters"/overloading is here not an issue (but i don't have the whole picture.. , so `,` could also cause it..by small chance) – xerx593 Oct 18 '21 at 11:08
  • please *add(/increase) logging* to `handleUserStatusChangedEvent`! ;) – xerx593 Oct 18 '21 at 11:14

3 Answers3

2

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.

Parth Manaktala
  • 1,112
  • 9
  • 27
  • You are right. Although it didn't solve the issue, I understood that the real problem was not the injection process. So, I double-checked the AWS-related configurations and found the bug. – Reza Ebrahimpour Oct 18 '21 at 13:34
1

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.

Reza Ebrahimpour
  • 814
  • 6
  • 20
Maziz
  • 322
  • 2
  • 13
0

I think that this can be related to using @SqsListener, so try putting this annotation to UserIntegrationTest:

@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)