在集成测试中覆盖 Spring Bean

1、概览

在 Spring 集成测试中,我们可能想要覆盖应用的一些 Bean。通常,可以使用专门为测试定义的 Spring Bean 来实现。然而,在 Spring Context 中提供多个具有相同名称的 Bean,可能会遇到 BeanDefinitionOverrideException 异常。

本文将带你了解如何在 Spring Boot 应用中 Mock 或 Stub 集成测试的 Bean,同时避免 BeanDefinitionOverrideException

2、在测试中使用 Mock 或 Stub

在深入了解细节之前,应该了解如何在测试中使用 Mock 或 Stub。这是一种强大的技术,可以确保应用不会出现错误。

也可以在 Spring 中采用这种方法。不过,只有在使用 Spring Boot 时才能直接模拟集成测试 Bean。

或者,也可以使用测试配置来 Stub 或 Mock bean。

3、Spring Boot 应用示例

创建一个简单的 Spring Boot 应用,包含了一个 Controller、Service 和一个 Configuration 类。

@RestController
public class Endpoint {

    private final Service service;

    public Endpoint(Service service) {
        this.service = service;
    }

    @GetMapping("/hello")
    public String helloWorldEndpoint() {
        return service.helloWorld();
    }
}

/hello 端点返回一个由 Service 提供的字符串,我们希望在测试过程中替换该字符串:

public interface Service {
    String helloWorld();
}

public class ServiceImpl implements Service {

    public String helloWorld() {
        return "hello world";
    }
}

需要注意的是,我们将使用一个接口。因此,当需要时,我们将对实现进行 Stub 操作以获得不同的值。

还需要一个配置来加载 Service Bean:

@Configuration
public class Config {

    @Bean
    public Service helloWorld() {
        return new ServiceImpl();
    }
}

最后,添加 @SpringBootApplication

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

4、使用 @MockBean 覆盖

MockBean 自 Spring Boot 1.4.0 版起就可用了。

不需要任何测试配置,只需在测试类中添加 @SpringBootTest 注解即可:

@SpringBootTest(classes = { Application.class, Endpoint.class })
@AutoConfigureMockMvc
class MockBeanIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private Service service;

    @Test
    void givenServiceMockBean_whenGetHelloEndpoint_thenMockOk() throws Exception {
        when(service.helloWorld()).thenReturn("hello mock bean");
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello mock bean")));
    }
}

这与 Main Configuration 没有冲突。这是因为 @MockBean 将向我们的应用注入一个 Service Mock 对象。

最后,使用 Mockito 伪造 Service 返回值:

when(service.helloWorld()).thenReturn("hello mock bean");

5、不使用 @MockBean 进行覆盖

最后了解一下在不使用 @MockBea 的情况下覆盖 Bean 的更多方式。

有四种不同的方法:Spring Profile、条件属性、@Primary 注解和 Bean 定义覆盖。然后,可以对 Bean 的实现进行 Stub 或 Mock。

5.1、使用 @Profile

定义 Profile 是 Spring 众所周知的做法。首先,使用 @Profile 创建一个配置:

@Configuration
@Profile("prod")
public class ProfileConfig {

    @Bean
    public Service helloWorld() {
        return new ServiceImpl();
    }
}

然后,就可以用 Service Bean 定义测试配置了:

@TestConfiguration
public class ProfileTestConfig {

    @Bean
    @Profile("stub")
    public Service helloWorld() {
        return new ProfileServiceStub();
    }
}

ProfileServiceStub Service 将 Stub 已定义的 ServiceImpl

public class ProfileServiceStub implements Service {

    public String helloWorld() {
        return "hello profile stub";
    }
}

我们可以创建一个包含 main 配置和测试配置的测试类:

@SpringBootTest(classes = { Application.class, ProfileConfig.class, Endpoint.class, ProfileTestConfig.class })
@AutoConfigureMockMvc
@ActiveProfiles("stub")
class ProfileIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void givenConfigurationWithProfile_whenTestProfileIsActive_thenStubOk() throws Exception {
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello profile stub")));
    }
}

我们在 ProfileIntegrationTest 中激活了 stub profile。因此,测试配置将加载 Service stub,而不会加载 prod profile。

5.2、使用 @ConditionalOnProperty

与 Profile 类似,可以使用 @ConditionalOnProperty 注解在不同的 Bean 配置之间切换。

在 Main Configuration 中设置 service.stub 属性:

@Configuration
public class ConditionalConfig {

    @Bean
    @ConditionalOnProperty(name = "service.stub", havingValue = "false")
    public Service helloWorld() {
        return new ServiceImpl();
    }
}

在运行时,需要将此条件设置为 false,通常是在 application.properties 文件中:

service.stub=false

相反,在测试配置中,我们希望触发 Service 加载。因此,需要此条件为 true

@TestConfiguration
public class ConditionalTestConfig {

    @Bean
    @ConditionalOnProperty(name="service.stub", havingValue="true")
    public Service helloWorld() {
        return new ConditionalStub();
    }
}

然后,再添加 Service stub:

public class ConditionalStub implements Service {

    public String helloWorld() {
        return "hello conditional stub";
    }
}

最后,创建测试类。将 service.stub 条件设置为 true,然后加载 Service stub:

@SpringBootTest(classes = {  Application.class, ConditionalConfig.class, Endpoint.class, ConditionalTestConfig.class }
, properties = "service.stub=true")
@AutoConfigureMockMvc
class ConditionIntegrationTest {

    @AutowiredService
    private MockMvc mockMvc;

    @Test
    void givenConditionalConfig_whenServiceStubIsTrue_thenStubOk() throws Exception {
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello conditional stub")));
    }
}

5.3、使用 @Primary

还可以使用 @Primary 注解。在 Main Configuration 中,在测试配置中定义一个 Primary Service,以更高的优先级加载:

@TestConfiguration
public class PrimaryTestConfig {

    @Primary
    @Bean("service.stub")
    public Service helloWorld() {
        return new PrimaryServiceStub();
    }
}

注意,Bean 的名称必须不同。否则,仍然会遇到原来的 BeanDefinitionOverrideException 异常。我们可以更改 @Beanname 属性或方法的名称。

同样,需要 Service stub:

public class PrimaryServiceStub implements Service {

    public String helloWorld() {
        return "hello primary stub";
    }
}

最后,通过定义所有相关组件来创建测试类:

@SpringBootTest(classes = { Application.class, NoProfileConfig.class, Endpoint.class, PrimaryTestConfig.class })
@AutoConfigureMockMvc
class PrimaryIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void givenTestConfiguration_whenPrimaryBeanIsDefined_thenStubOk() throws Exception {
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello primary stub")));
    }
}

5.4、使用 spring.main.allow-bean-definition-overriding 属性

如果之前的办法都不灵,咋办?Spring 提供了 spring.main.allow-bean-definition-overriding 属性,可以直接覆盖 main configuration。

定义测试配置:

@TestConfiguration
public class OverrideBeanDefinitionTestConfig {

    @Bean
    public Service helloWorld() {
        return new OverrideBeanDefinitionServiceStub();
    }
}

然后,需要 Service stub:

public class OverrideBeanDefinitionServiceStub implements Service {

    public String helloWorld() {
        return "hello no profile stub";
    }
}

再次创建一个测试类。如果要覆盖 Service Bean,需要将属性设置为 true

@SpringBootTest(classes = { Application.class, Config.class, Endpoint.class, OverribeBeanDefinitionTestConfig.class }, 
  properties = "spring.main.allow-bean-definition-overriding=true")
@AutoConfigureMockMvc
class OverrideBeanDefinitionIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void givenNoProfile_whenAllowBeanDefinitionOverriding_thenStubOk() throws Exception {
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello no profile stub")));
    }
}

5.5、使用 Mock 代替 Stub

到目前为止,在使用测试配置时,我们已经看到了使用 Stub 的例子。然而,我们也可以对一个 Bean 进行 Mock。这对于之前看到的任何测试配置都适用。这里以 Profile 为例,进行演示:

这一次,不再使用 Stub,而是使用 Mockito mock 方法返回一个 Service

@TestConfiguration
public class ProfileTestConfig {

    @Bean
    @Profile("mock")
    public Service helloWorldMock() {
        return mock(Service.class);
    }
}

同样,创建一个测试类,激活 mock Profile:

@SpringBootTest(classes = { Application.class, ProfileConfig.class, Endpoint.class, ProfileTestConfig.class })
@AutoConfigureMockMvc
@ActiveProfiles("mock")
class ProfileIntegrationMockTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Service service;

    @Test
    void givenConfigurationWithProfile_whenTestProfileIsActive_thenMockOk() throws Exception {
        when(service.helloWorld()).thenReturn("hello profile mock");
        this.mockMvc.perform(get("/hello"))
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("hello profile mock")));
    }
}

其工作原理与 @MockBean 类似。不过,使用 @Autowired 注解将 Bean 注入测试类。与 Stub 相比,这种方法更加灵活,可以在测试用例中直接使用 when/then 语法。

6、总结

本文介绍了在 Spring 集成测试中覆盖 Bean 的几种方式,包括使用 @MockBean@Profile@ConditionalOnProperty@Primary 注解以及 allow-bean-definition-overriding 属性。


Ref:https://www.baeldung.com/spring-beans-integration-test-override