在Spring Boot集成测试中模拟外部依赖项。

6

主要问题:有没有一种方法可以在整个Spring上下文中用模拟对象替换bean,并将确切的bean注入测试中以验证方法调用?

我有一个Spring Boot应用程序,我正在尝试编写一些集成测试,其中我使用MockMvc调用Rest API。

集成测试针对实际数据库和使用TestcontainerLocalstack的AWS资源运行。但是,为了测试与Keycloak集成作为外部依赖关系的API,我决定模拟KeycloakService并验证正确的参数是否传递给了该类的适当函数。

所有我的集成测试类都是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;
}

假设有以下这个子类:

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
    }
}

这是调用 KeycloakService 函数的类,基于事件:

@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());
    }
}

无论何时运行测试,都会出现以下错误:
Wanted but not invoked:
keycloakService bean.changeUserStatus(
    "rodolfo.kautzer@example.com",
    true
);

Actually, there were zero interactions with this mock.

在调试代码后,我理解到由于上下文重新加载,在 UserIntegrationTest 中模拟的 bean 与注入到 UserEventSQSListener 类中的 bean 并不相同。因此,我尝试其他解决方案,如使用 Mockito.mock() 创建 mock 对象并将其作为 bean 返回,以及使用 @MockInBean,但它们也无法解决问题。
    @TestConfiguration
    public static class TestBeanConfig {

        @Bean
        @Primary
        public KeycloakService keycloakService() {
            KeycloakService keycloakService = Mockito.mock(KeycloakService.class);
            return keycloakService;
        }
    }

更新1:

根据 @Maziz 的回答,并为了调试目的,我将代码更改如下:

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

正如你所看到的,mock 已经被适当地替换在 UserEventSQSListener 类中:

debug the injection

然而,我遇到了以下错误:

Wanted but not invoked:
keycloakService.changeUserStatus(
    <any string>,
    <any boolean>
);
Actually, there were zero interactions with this mock.

你确定你的LocalStack SQS集成测试设置正常工作,并且事件已经到达了你的监听器吗? - rieckpil
是的,我这么做了。起初,我也有同样的想法,但如果我注入真正的 KeycloakService bean,则更改将应用于 Keycloak 端。 - Reza Ebrahimpour
如果keycloakService模拟bean没有被注入,那么实际的bean是否被初始化并调用了实际的服务? - Parth Manaktala
我没有完全阅读问题,但是使用带有覆盖选项的@TestConfiguration对您不起作用吗? - Eugene
由于我测试了不同的解决方案,我错过了轨道。但在某些情况下,实际的bean被初始化并注入到UserEventSQSListener中,因此更改将应用于Keycloak。在某些情况下,UserEventSQSListener将再次初始化,并使用构造函数将新的模拟对象注入其中,但是测试类中的引用不会更改。因此,另一个模拟对象将被调用,并且验证将发生在另一个模拟对象上,并且将抛出错误。@ParthManaktala - Reza Ebrahimpour
显示剩余2条评论
3个回答

2
根据Maziz的答案,setField这行代码不应该在mvc调用之前吗?
@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
    }

如果这仍然不起作用,您可以将该行替换为:
org.powermock.reflect.Whitebox.setInternalState(UserEventSQSListener.class, "keycloakService", keycloakService);

但是总体思路保持不变。

你是对的。虽然它没有解决问题,但我明白真正的问题不是注入过程。所以,我再次检查了与AWS相关的配置,并找到了错误。 - Reza Ebrahimpour

1

您是否在UserEventSQSListener中对KeyClockService进行了调试?您是否看到对象是类型代理,表示模拟对象?

无论答案如何,在调用mockMvc.perform之前,可以使用

ReflectionTestUtils.setField(UserEventSQSListener, "keycloakService", keycloakService /*the mock object*/)

再运行一遍。让我知道它是否正常。

感谢您的解决方案。由于出现了NullPointerException,使用完全相同的代码行并没有起作用。但是,我稍微修改了一下并更新了问题。请看一下。 - Reza Ebrahimpour
@RezaEbrahimpour 我认为setField应该在mvc调用之前。 - Parth Manaktala

0

我认为这可能与使用@SqsListener有关,因此请尝试将此注释放置到UserIntegrationTest中:

@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接