Mocking with (and without) Spring Boot

使用(和不使用)Spring Boot 进行模拟

Mockito 是一个非常流行的支持测试的库。它允许我们用“模拟”替换真实的对象,即用不是真实的对象并且我们可以在测试中控制其行为的对象。

本文简要介绍了 Mockito 和 Spring Boot 与其集成的方式和原因。

The System Under Test(被测系统)

在深入了解模拟的细节之前,让我们先看看我们要测试的应用程序。我们将使用一些基于我书中支付示例应用程序“buckpal”的代码。

本文所测试的系统将是一个 Spring REST 控制器,它接受将资金从一个帐户转移到另一个帐户的请求:

@RestController
@RequiredArgsConstructor
public class SendMoneyController {

  private final SendMoneyUseCase sendMoneyUseCase;

  @PostMapping(path = "/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}")
  ResponseEntity sendMoney(
          @PathVariable("sourceAccountId") Long sourceAccountId,
          @PathVariable("targetAccountId") Long targetAccountId,
          @PathVariable("amount") Integer amount) {
  
    SendMoneyCommand command = new SendMoneyCommand(
            sourceAccountId,
            targetAccountId,
            amount);
  
    boolean success = sendMoneyUseCase.sendMoney(command);
    
    if (success) {
      return ResponseEntity
              .ok()
              .build();
    } else {
      return ResponseEntity
              .status(HttpStatus.INTERNAL_SERVER_ERROR)
              .build();
    }
  }

}

控制器将输入传递给 SendMoneyUseCase 的实例,该实例是具有单个方法的接口:

public interface SendMoneyUseCase {

  boolean sendMoney(SendMoneyCommand command);

  @Value
  @Getter
  @EqualsAndHashCode(callSuper = false)
  class SendMoneyCommand {

    private final Long sourceAccountId;
    private final Long targetAccountId;
    private final Integer money;

    public SendMoneyCommand(
            Long sourceAccountId,
            Long targetAccountId,
            Integer money) {
      this.sourceAccountId = sourceAccountId;
      this.targetAccountId = targetAccountId;
      this.money = money;
    }
  }

}

最后,我们有一个实现 SendMoneyUseCase 接口的虚拟服务:

@Slf4j
@Component
public class SendMoneyService implements SendMoneyUseCase {

  public SendMoneyService() {
    log.info(">>> constructing SendMoneyService! <<<");
  }

  @Override
  public boolean sendMoney(SendMoneyCommand command) {
    log.info("sending money!");
    return false;
  }

}

想象一下,在这个类中有一些非常复杂的业务逻辑正在代替日志语句。

对于本文的大部分内容,我们对 SendMoneyUseCase 接口的实际实现不感兴趣。毕竟,我们想在 Web 控制器的测试中模拟它。

Why Mock?(为什么要模拟?)

为什么我们应该在测试中使用模拟而不是真实的服务对象?

想象一下,上面的服务实现依赖于数据库或其他第三方系统。我们不想让我们的测试针对数据库运行。如果数据库不可用,即使我们的测试系统可能完全没有错误,测试也会失败。我们在测试中添加的依赖项越多,测试失败的原因就越多。大多数这些原因都是错误的。如果我们改用模拟,我们可以模拟所有那些潜在的失败。

除了减少失败之外,模拟还降低了我们测试的复杂性,从而为我们节省了一些精力。建立一个由正确初始化的对象组成的整个网络以用于测试需要大量的样板代码。使用模拟,我们只需要“实例化”一个模拟,而不是一整串对象,真实对象可能需要被实例化。

总之,我们希望从潜在的复杂、缓慢和不稳定的集成测试转向简单、快速和可靠的单元测试。

因此,在上面的 SendMoneyController 测试中,我们希望使用具有相同接口的模拟,而不是 SendMoneyUseCase 的真实实例,我们可以在测试中根据需要控制其行为。

Mocking with Mockito (and without Spring)用 Mockito 模拟(没有 Spring)

作为一个模拟框架,我们将使用 Mockito,因为它全面、完善并且很好地集成到 Spring Boot 中。

但是最好的测试根本不使用 Spring,所以让我们首先看看如何在普通的单元测试中使用 Mockito 来模拟不需要的依赖项。

Plain Mockito Test(普通模拟测试)

使用 Mockito 最简单的方法是简单地使用 Mockito.mock() 实例化一个模拟对象,然后将这样创建的模拟对象传递给被测类:

public class SendMoneyControllerPlainTest {

  private SendMoneyUseCase sendMoneyUseCase = 
      Mockito.mock(SendMoneyUseCase.class);

  private SendMoneyController sendMoneyController = 
      new SendMoneyController(sendMoneyUseCase);

  @Test
  void testSuccess() {
    // given
    SendMoneyCommand command = new SendMoneyCommand(1L, 2L, 500);
    given(sendMoneyUseCase
        .sendMoney(eq(command)))
        .willReturn(true);
  
    // when
    ResponseEntity response = sendMoneyController
        .sendMoney(1L, 2L, 500);
  
    // then
    then(sendMoneyUseCase)
        .should()
        .sendMoney(eq(command));
  
    assertThat(response.getStatusCode())
        .isEqualTo(HttpStatus.OK);
  }

}

我们创建一个 SendMoneyService 的模拟实例并将这个模拟传递给 SendMoneyController 的构造函数。控制器不知道它是一个模拟对象,并且会像对待真实事物一样对待它。

Web 控制器应该经过集成测试!

不要在家里这样做!上面的代码只是如何创建模拟的示例。像这样使用单元测试来测试 Spring Web Controller 只涵盖了生产中可能发生的一小部分潜在错误。上面的单元测试验证返回了一定的响应码,但是并没有集成Spring来检查输入参数是否从HTTP请求中正确解析,或者控制器是否监听到了正确的路径,或者异常是否被转化为预期的 HTTP 响应,等等。

正如我关于@WebMvcTest 注解的文章中所讨论的那样,Web 控制器应该与 Spring 集成进行测试。

在 JUnit Jupiter 中使用 Mockito 注释

Mockito 提供了一些方便的注释,可以减少创建模拟实例并将它们传递给我们即将测试的对象的手动工作。

使用 JUnit Jupiter,我们需要将 MockitoExtension 应用到我们的测试中:

@ExtendWith(MockitoExtension.class)
class SendMoneyControllerMockitoAnnotationsJUnitJupiterTest {

  @Mock
  private SendMoneyUseCase sendMoneyUseCase;

  @InjectMocks
  private SendMoneyController sendMoneyController;

  @Test
  void testSuccess() {
    ...
  }

}

我们可以在测试的字段上使用@Mock 和@InjectMocks 注释。

使用 @Mock 注释的字段将自动使用其类型的模拟实例进行初始化,就像我们手动调用 Mockito.mock() 一样。

然后,Mockito 将尝试通过将所有模拟传递给构造函数来实例化带有 @InjectMocks 注释的字段。请注意,我们需要为 Mockito 提供这样的构造函数才能可靠地工作。如果 Mockito 没有找到构造函数,它会尝试 setter 注入或字段注入,但最干净的方法仍然是构造函数。您可以在 Mockito 的 Javadoc 中了解这背后的算法。

在 JUnit 4 中使用 Mockito 注释

与 JUnit 4 非常相似,只是我们需要使用 MockitoJUnitRunner 而不是MockitoExtension

@RunWith(MockitoJUnitRunner.class)
public class SendMoneyControllerMockitoAnnotationsJUnit4Test {

  @Mock
  private SendMoneyUseCase sendMoneyUseCase;

  @InjectMocks
  private SendMoneyController sendMoneyController;

  @Test
  public void testSuccess() {
    ...
  }

}

Mocking with Mockito and Spring Boot

有时我们不得不依赖 Spring Boot 来为我们设置应用程序上下文,因为手动实例化整个类网络的工作量太大。

但是,我们可能不想在某个测试中测试所有 bean 之间的集成,因此我们需要一种方法来用模拟替换 Spring 应用程序上下文中的某些 bean。 Spring Boot 为此提供了@MockBean 和@SpyBean 注解。

Adding a Mock Spring Bean with @MockBean

使用模拟的一个主要示例是使用 Spring Boot 的 @WebMvcTest 创建一个应用程序上下文,其中包含测试 Spring Web 控制器所需的所有 bean:

@WebMvcTest(controllers = SendMoneyController.class)
class SendMoneyControllerWebMvcMockBeanTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private SendMoneyUseCase sendMoneyUseCase;

  @Test
  void testSendMoney() {
    ...
  }

}

**@WebMvcTest 创建的应用程序上下文不会选择我们的 SendMoneyService bean(它实现了 SendMoneyUseCase 接口),即使它被标记为带有 @Component 注释的 Spring bean。**我们必须自己提供一个 SendMoneyUseCase 类型的 bean,否则,我们会得到这样的错误:

No qualifying bean of type 'io.reflectoring.mocking.SendMoneyUseCase' available:
  expected at least 1 bean which qualifies as autowire candidate.

与其自己实例化 SendMoneyService 或告诉 Spring 来选择它,可能会在流程中拉入其他 bean 的鼠尾,我们只需将 SendMoneyUseCase 的模拟实现添加到应用程序上下文中即可。

这可以通过使用 Spring Boot 的 @MockBean 注解轻松完成。然后,Spring Boot 测试支持将自动创建一个 SendMoneyUseCase 类型的 Mockito 模拟并将其添加到应用程序上下文中,以便我们的控制器可以使用它。在测试方法中,我们可以像上面一样使用 Mockito 的 given() 和 when() 方法。

通过这种方式,我们可以轻松地创建一个集中的 Web 控制器测试,它只实例化它需要的对象。

用 @MockBean 替换 Spring Bean

除了添加新的(模拟)bean,我们可以类似地使用@MockBean 将应用程序上下文中已经存在的bean 替换为模拟:

@SpringBootTest
@AutoConfigureMockMvc
class SendMoneyControllerSpringBootMockBeanTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private SendMoneyUseCase sendMoneyUseCase;

  @Test
  void testSendMoney() {
    ...
  }

}

请注意,上面的测试使用@SpringBootTest 而不是@WebMvcTest,这意味着将为此测试创建 Spring Boot 应用程序的完整应用程序上下文。这包括我们的 SendMoneyService bean,因为它带有 @Component 注释并且位于我们应用程序类的包结构中。

@MockBean 注解将导致 Spring 在应用程序上下文中查找类型为 SendMoneyUseCase 的现有 bean。如果存在,它将用 Mockito 模拟替换该 bean。

总结:

  • 在使用@WebMvcTest注解的时候,创建的应用程序上下文不会选择程序中原有的bean,我们使用@MockBean注解模拟并将其添加到应用程序上下文中。
  • 在使用@SpringBootTest注解的时候会加载程序中所有的应用程序上下文的bean,我们这是使用@MockBean是为了创建一个模拟的bean将测试使用这个摸你的而不是真实的。

使用 @SpyBean 监视 Spring Bean

Mockito 还允许我们监视真实对象。 Mockito 不是完全模拟一个对象,而是围绕真实对象创建一个代理,并简单地监视正在调用哪些方法,我们以后可以验证是否调用了某个方法。

Spring Boot 为此提供了@SpyBean 注解:

@SpringBootTest
@AutoConfigureMockMvc
class SendMoneyControllerSpringBootSpyBeanTest {

  @Autowired
  private MockMvc mockMvc;

  @SpyBean
  private SendMoneyUseCase sendMoneyUseCase;

  @Test
  void testSendMoney() {
    ...
  }

}

@SpyBean 就像@MockBean 一样工作。它不是在应用程序上下文中添加 bean 或替换 bean,而是简单地将 bean 包装在 Mockito 的代理中。在测试中,我们可以像上面一样使用 Mockito 的 then() 来验证方法调用。

如果我们在测试中大量使用@MockBean 和@SpyBean,那么运行测试会花费很多时间。这是因为 Spring Boot 会为每个测试创建一个新的应用程序上下文,这可能是一项昂贵的操作,具体取决于应用程序上下文的大小。

Conclusion

Mockito 让我们可以轻松地模拟掉我们现在不想测试的对象。这可以减少我们测试中的集成开销,甚至可以将集成测试转换为更集中的单元测试。

Spring Boot 通过使用 @MockBean 和 @SpyBean 注解,可以在 Spring 支持的集成测试中轻松使用 Mockito 的模拟功能。

尽管这些 Spring Boot 功能很容易包含在我们的测试中,但我们应该意识到成本:每个测试都可能创建一个新的应用程序上下文,这可能会显着增加我们测试套件的运行时间。

### Ideal Practices for a Spring Boot Project An ideal Spring Boot project adheres closely to established conventions and best practices within the software development community. For instance, when handling exceptions in such projects, one should aim at catching specific exceptions rather than just using `Exception`, which is the base class of all exceptions[^1]. However, there are scenarios where it might be necessary to catch all types of exceptions; this can be achieved by specifying `Exception` as the parameter type in the catch clause. In terms of technology stack familiarity, having knowledge in Java would significantly benefit developers working on Spring Boot applications since both technologies share common ground and principles[^2]. For structuring an optimal Spring Boot application: - **Project Structure**: Organize packages logically based on functionality (e.g., controllers, services, repositories). This approach enhances readability and maintainability. - **Configuration Management**: Utilize externalized configuration files like `.properties` or `.yml`. These configurations allow easy adjustments without altering source code directly. - **Dependency Injection & Inversion of Control**: Leverage these design patterns through annotations provided by Spring Framework (`@Autowired`, `@Component`, etc.) to manage dependencies effectively between components. - **Security Measures**: Implement security measures from day one utilizing libraries compatible with Spring Security framework ensuring data integrity and user authentication/authorization mechanisms. - **Testing Strategy**: Develop comprehensive unit tests alongside integration testing strategies employing tools like JUnit along with mocking frameworks such as Mockito. ```java // Example demonstrating dependency injection via constructor @Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } // Service methods... } ``` Regarding query optimization techniques mentioned previously about matching fields during searches, while not directly related to building Spring Boot apps, similar concepts apply when designing efficient database interactions within your service layer[^3].
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值