Appearance
33组件测试:如何使用Mock和注解实施组件级别测试?
在上一课时中,我们全面介绍了针对微服务架构的测试方案。我们提出在测试微服务架构中需要直接面对的两个核心问题,即如何验证组件级别的正确性以及如何验证服务级别的正确性。本课时和下一课时的内容将分别围绕这两个核心问题进行展开,今天让我们先来看一下组件级别的测试方法和工程实践。
组件级别的测试方案
在上一课时中,我们已经讨论到使用 Mock 来对组件进行测试。Mock 是一种策略而不是技术,今天我们就需要给出如何实现 Mock 的技术体系。假设在 intervention-service 中存在这样一个 InterventionService 类,其中包含一个 getInterventionById 方法,如下所示:
java
@Service
public class InterventionService {
public Intervention getInterventionById(Long id) {
...
}
}
那么,如何对这个方法进行 Mock 呢?通常,我们可以使用 easymock、jmockMock 等工具包来隐式实现这个方法。对于某一个或一些被测试对象所依赖的方法而言,编写 Mock 相对简单,只需要模拟被使用的方法即可。在这个例子中,如果依赖于 InterventionService,我们只需要给出 getInterventionById 方法的实现。
让我们回到单个微服务的内部,涉及组件级别测试的维度有很多,包括数据访问 Repository 层、服务构建 Service 层和提供外部端点的 Controller 层。同时,基于常见的代码组织结构,组件测试也体现为一种层次关系,即我们需要测试从 Repository 层到 Service 层再到 Controller 层的完整业务链路。
另一方面,Spring Boot 也内置了一个测试模块可以用于组件级别的测试场景。在该模块中,提供了一批非常有用的注解来简化测试过程,要想使用这些注解,我们需要引入 spring-boot-starter-test 依赖,如下所示:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
首先,因为 Spring Boot 程序的入口是 Bootstrap 类,Spring Boot 专门提供了一个 @SpringBootTest 注解来测试你的 Bootstrap 类,使用方法如下所示:
java
@SpringBootTest(classes = UserApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.MOCK)
在 Spring Boot 中,@SpringBootTest 注解主要用于测试基于自动配置的 ApplicationContext,它允许你来设置测试上下文中的 Servlet 环境。在多数场景下,一个真实的 Servlet 环境对于测试而言过于重量级,所以我们一般通过 WebEnvironment.MOCK 环境来模拟测试环境。
我们知道对于一个 Spring Boot 应用程序而言,Bootstrap 类中的 main() 入口通过 SpringApplication.run() 方法将启动 Spring 容器。如下所示的 intervention-service 中的启动类 InterventionApplication:
java
@SpringBootApplication
public class InterventionApplication {
public static void main(String[] args) {
SpringApplication.run(InterventionApplication.class, args);
}
}
针对这个 BootStrap 类,我们可以通过编写测试用例的方式来验证 Spring 容器是否能够正常启动,该测试用例如下所示:
java
package com.tianyalan.testing.orders;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit4.SpringRunner;
@SpringBootTest
@RunWith(SpringRunner.class)
public class ApplicationContextTests {
@Autowired
private ApplicationContext applicationContext;
@Test
public void testContextLoaded() throws Throwable {
Assert.assertNotNull(this.applicationContext);
}
}
我们看到这里用到了 @SpringBootTest 注解和 @RunWith 注解。前面已经介绍了 @SpringBootTest 注解,而 @RunWith 注解由 JUnit 框架提供,用于设置测试运行器,例如我们可以通过 @RunWith(SpringRunner.class) 让测试运行于 Spring 测试环境中。
同时,我们在 testContextLoads 方法上添加了一个 @Test 注解,该注解来自 JUnit 框架,代表该方法为一个有效的测试用例。这里测试的场景是指对 Spring 中的 ApplicationContext 作了非空验证。执行该测试用例,我们从输出的控制台信息中看到 Spring Boot 应用程序被正常启动,同时测试用例本身也会给出执行成功的提示。
在验证完容器可以正常启动之后,我们继续来看一个 Spring Boot 应用程序的其他组件层。对于 Repository 层而言,主要的交互媒介是数据库,所以 Spring Boot 专门提供了一个 @DataJpaTest 注解来模拟基于 JPA 规范的数据访问过程。同样,对于 Controller 层而言,Spring Boot 也提供了一个 @WebMvcTest 注解来模拟 Web 交互的测试场景。
讲到这里,你可能会奇怪,为什么 Service 层没有专门的测试注解呢?实际上原因也很简单,因为对于 Repository 层和 Controller 层组件而言,它们都涉及与某一种特定技术体系的交互,Repository 层的交互对象是数据库,而 Controller 层的交互对象是 Web 请求,所以需要专门的测试注解。而 Service 层因为主要是业务代码,并没有跟具体某一项技术体系有直接的关联,所以我们在测试过程中只需要充分使用 Mock 机制就可以了。下图展示了一个业务微服务中各层的测试方法:
组件测试的层次和实现方式
接下来,我们就将对上图中的三个层次和对应的实现方法分别展开讨论。
Repository 层:@DataJpaTest 注解
对于业务微服务而言,一般都涉及数据持久化,我们将首先从数据持久化的角度出发讨论如何对 Repository 层进行测试,并引入 @DataJpaTest 注解。@DataJpaTest 注解会自动注入各种 Repository 类,并会初始化一个内存数据库及访问该数据库的数据源。为了演示方便,我们使用 h2 作为内存数据库,并通过 Mysql 实现数据持久化,因此需要引入以下 Maven 依赖。
xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
让我们回顾 SpringHealth 案例系统中的 intervention-service 中的 InterventionRepository 接口,如下所示:
java
public interface InterventionRepository extends JpaRepository<Intervention, Long> {
List<Intervention> findInterventionsByUserId(@Param("userId") String userId);
List<Intervention> findInterventionsByDeviceId(@Param("deviceId") String deviceId);
}
注意到这里 InterventionRepository 扩展了 Spring Data 中的 JpaRepository 接口。针对该 InterventionRepository 接口的测试用例如下所示:
java
@RunWith(SpringRunner.class)
@DataJpaTest
public class InterventionRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private InterventionRepository interventionRepository;
@Test
public void testFindInterventionByUserId() throws Exception {
this.entityManager.persist(new Intervention(1L, 1L, 100F, "Intervention1", new Date()));
this.entityManager.persist(new Intervention(1L, 2L, 200F, "Intervention2", new Date()));
Long userId = 1L;
List<Intervention> interventions = this.interventionRepository.findInterventionsByUserId(userId);
assertThat(interventions).size().isEqualTo(2);
Intervention actual = interventions.get(0);
assertThat(actual.getUserId()).isEqualTo(userId);
}
@Test
public void testFindInterventionByNonExistedUserId() throws Exception {
this.entityManager.persist(new Intervention(1L, 1L, 100F, "Intervention1", new Date()));
this.entityManager.persist(new Intervention(1L, 2L, 200F, "Intervention2", new Date()));
Long userId = 3L;
List<Intervention> interventions = this.interventionRepository.findInterventionsByUserId(userId);
assertThat(interventions).size().isEqualTo(0);
}
}
可以看到这里使用了 @DataJpaTest 以完成 InterventionRepository 的注入。同时,我们还注意到另一个核心测试组件 TestEntityManager,该类内部定义了一个 EntityManagerFactory 变量,而 EntityManagerFactory 能够构建数据持久化操作所需要的 EntityManager 对象。所以,TestEntityManager 的效果相当于不使用真正的 InterventionRepository 来完成数据的持久化,从而提供了一种数据与环境之间的隔离机制。TestEntityManager 中所包含的方法如下所示:
TestEntityManager 中的方法定义列表
基于 InterventionRepository 中的方法定义以及我们初始化的数据,以上测试用例的结果显而易见。你可以尝试执行这些单元测试,并观察控制台的日志输出,从这些日志中可以看出各种 SQL 语句的效果。
Service 层:Mock
前面我们已经介绍了 @SpringBootTest 注解中的 SpringBootTest.WebEnvironment.MOCK选项,该选项用于加载 WebApplicationContext 并提供一个 Mock 的 Servlet 环境,内置的 Servlet 容器并没有真实的启动。现在,我们就针对 Service 层来演示这种测试方式。
InterventionService 类的 generateIntervention 方法是其最核心的方法,涉及对 user-service 和 device-service 的远程调用,让我们做一些回顾:
java
public Intervention generateIntervention(String userName, String deviceCode) {
logger.debug("Generate intervention record with user: {} from device: {}", userName, deviceCode);
Intervention intervention = new Intervention();
//获取远程 User 信息
UserMapper user = getUser(userName);
if (user == null) {
return intervention;
}
logger.debug("Get remote user: {} is successful", userName);
//获取远程 Device 信息
DeviceMapper device = getDevice(deviceCode);
if (device == null) {
return intervention;
}
logger.debug("Get remote device: {} is successful", deviceCode);
//创建并保存 Intervention 信息
intervention.setUserId(user.getId());
intervention.setDeviceId(device.getId());
intervention.setHealthData(device.getHealthData());
intervention.setIntervention("InterventionForDemo");
intervention.setCreateTime(new Date());
interventionRepository.save(intervention);
return intervention;
}
请注意以上代码中的 getUser 方法和 getDevice 方法中涉及了远程访问。以 getUser 方法为例,就会基于 UserServiceClient 发送HTTP请求,我们在前面的课程中都已经介绍过这个类,这里也做一下回顾:
java
@Component
public class UserServiceClient {
@Autowired
RestTemplate restTemplate;
public UserMapper getUserByUserName(String userName){
ResponseEntity<UserMapper> restExchange =
restTemplate.exchange(
"http://userservice/users/{userName}",
HttpMethod.GET,
null, UserMapper.class, userName);
UserMapper user = restExchange.getBody();
return user;
}
}
对于测试而言,InterventionService 类实际上不需要关注这个 UserServiceClient 中如何实现远程访问的具体过程,因为对于测试过程而言只需要关注方法调用返回的结果。所以,我们对于 UserServiceClient 以及 DeviceServiceClient 同样将采用 Mock 机制完成隔离。针对 InterventionService 的测试用例代码如下所示,可以看到我们采用的是同样的测试方式:
java
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class InterventionServiceTests {
@MockBean
private UserServiceClient userClient;
@MockBean
private DeviceServiceClient deviceClient;
@MockBean
private InterventionRepository interventionRepository;
@Autowired
private InterventionService interventionService;
@Test
public void testGenerateIntervention() throws Exception {
String userName = "springhealth_user1";
String deviceCode = "device1";
given(this.userClient.getUserByUserName(userName))
.willReturn(new UserMapper(1L, "user1", userName));
given(this.deviceClient.getDevice(deviceCode))
.willReturn(new DeviceMapper(1L, "便携式血压计", "device1", "Sphygmomanometer", 100F));
Intervention actual = interventionService.generateIntervention(userName, deviceCode);
assertThat(actual.getHealthData()).isEqualTo(100L);
}
}
这里同样基于 mockito 对 UserServiceClient 和 DeviceServiceClient 这两个远程访问类的返回结果做了模拟。上述测试用例演示了在 Service 层中进行集成测试的各种手段,这些手段已经能够满足一般场景的需要。
Controller 层:@WebMvcTest 注解
我们再回到 intervention-service 来看看如何对 InterventionController 进行测试。InterventionController 类的功能非常简单,基本都是对 InterventionService 的直接封装,代码如下所示:
java
@RestController
@RequestMapping(value="interventions")
public class InterventionController {
@Autowired
private InterventionService interventionService;
@RequestMapping(value = "/{userName}/{deviceCode}", method = RequestMethod.POST)
public Intervention generateIntervention( @PathVariable("userName") String userName,
@PathVariable("deviceCode") String deviceCode) {
Intervention intervention = interventionService.generateIntervention(userName, deviceCode);
return intervention;
}
@RequestMapping(value = "/{id}", method = RequestMethod.GET)
public Intervention getIntervention(@PathVariable Long id) {
Intervention intervention = interventionService.getInterventionById(id);
return intervention;
}
}
在测试 Controller 类之前,我们先介绍一个新的注解 @WebMvcTest,该注解将初始化测试 Controller 所必需的 Spring MVC 基础设施。InterventionController 类的测试用例如下所示:
java
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@WebMvcTest(InterventionController.class)
public class InterventionControllerTests {
@Autowired
private MockMvc mvc;
@MockBean
private InterventionService interventionService;
@Test
public void testGenerateIntervention() throws Exception {
String userName = "springhealth_user1";
String deviceCode = "device1";
Intervention intervention = new Intervention(100L, 1L, 1L, 100F, "Intervention1", new Date());
given(this.interventionService.generateIntervention(userName, deviceCode))
.willReturn(intervention);
this.mvc.perform(post("/interventions/" + userName+ "/" + deviceCode).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk());
}
}
以上代码的关键是 MockMvc 工具类,对这个工具类我们有必要展开一下。MockMvc 类提供的一系列基础方法来满足对 Controller 层组件的测试需求。首先,我们首先需要声明发送 HTTP 请求的方式,MockMvc 类中的一组 get/post/put/delete 方法用来初始化一个 HTTP 请求。然后我们可以使用 param 方法来为该请求添加参数。一旦请求构建完成,perform 方法负责执行请求,并自动将请求映射到相应的 Controller 进行处理。执行完请求之后就是验证结果,这时候可以使用 andExpect、andDo 和 andReturn 等方法对返回的数据进行判断来验证 HTTP 请求执行结果是否正确。
执行该测试用例,我们从输出的控制台日志中不难发现整个流程相当于启动了 InterventionController 并执行远程访问,而 InterventionController 中所用到的 InterventionService 则做了 Mock。显然测试 InterventionController 的目的在于验证请求是否成功发送和返回,所以我们通过 perform、accept 和 andExpect 方法最终模拟 HTTP 请求的整个过程并验证结果的正确性。
小结与预告
今天的课程讨论了如何对单个微服务中的各个组件进行测试,我们大量使用到了 Spring 框架中的测试注解。作为小结,这里通过一张表格来对这些注解做一个梳理,如下所示:
这里给你留一道思考题:如果我们想要对所依赖的组件的行为进行模拟,可以使用什么方法?
讲完组件级别的测试方法之后,下一课时,我们将关注于基于服务级别测试用例的设计,并将引入 Spring Cloud Contract 框架来实施这一过程。