【问题标题】:Mocking external dependencies in Spring Boot integration tests在 Spring Boot 集成测试中模拟外部依赖项
【发布时间】:2021-12-03 01:00:56
【问题描述】:

主要问题:有没有办法在 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() 创建一个模拟对象并将其作为 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
    }

如您所见,模拟在 UserEventSQSListener 类中被适当地替换:

我仍然收到以下错误:

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

【问题讨论】:

  • 您确定您的集成测试的 LocalStack SQS 设置正在工作并且事件到达您的侦听器?
  • 是的。我愿意。一开始我也是这么想的,但是如果我注入真正的KeycloakServices bean,更改将应用​​在 Keycloak 端。
  • 如果 keycloakService 模拟 bean 没有被注入,实际的 bean 是否正在初始化并调用实际的服务?
  • 我没有阅读整个问题,但是 @TestConfiguration 带有覆盖选项对你不起作用?
  • 由于我测试了不同的解决方案,我错过了赛道。尽管如此,在某些情况下,实际的 bean 会被初始化并注入到UserEventSQSListener,因此更改会应用于 Keycloak。在某些情况下,UserEventSQSListener 会再次初始化,并使用构造函数将新的模拟对象注入其中,但测试类中的引用不会改变。因此将调用另一个模拟对象,并且将在另一个模拟对象上进行验证并抛出错误。 @ParthManaktala

标签: java spring spring-boot mockito spring-test


【解决方案1】:

根据 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相关的配置,发现了这个bug。
【解决方案2】:

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

不管答案如何,在调用mockMvc.perform之前,都可以使用

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

再次运行。让我知道是否可以。

【讨论】:

  • 感谢您的解决方案。由于 NullPointerException,使用确切的行不起作用。但是,我对其进行了一些更改并更新了问题。请看一下。
  • @RezaEbrahimpour 我认为 setField 应该在 mvc 调用之前进行。
【解决方案3】:

我认为这可能与使用@SqsListener有关,所以尝试把这个注解放到UserIntegrationTest

@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2017-06-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-11-24
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多