基于spring-boot的应用程序的单元测试方案
概述
本文主要介绍如何对基于spring-boot的web应用编写单元测试、集成测试的代码。
此类应用的架构图一般如下所示:
我们项目的程序,对应到上图中的web应用部分。这部分一般分为Controller层、service层、持久层。除此之外,应用程序中还有一些数据封装类,我们称之为domain。上述各组件的职责如下:
- Controller层/Rest接口层: 负责对外提供Rest服务,接收Rest请求,返回处理结果。
- service层: 业务逻辑层,根据Controller层的需要,实现具体的逻辑。
- 持久层: 访问数据库,进行数据的读写。向上支撑service层的数据库访问需求。
在Spring环境中,我们通常会把这三层注册到Spring容器,上图中使用浅蓝色背景就是为了表示这一点。
在本文的后续内容,我们将介绍如何对应用进行集成测试,包括启动web容器的请求测试、不启动web容器而使用模拟环境的测试;介绍如何对应用进行单元测试,包括单独测试Controller层、service层、持久层。
集成测试和单元测试的区别是,集成测试通常只需要测试最上面一层,因为上层会自动调用下层,所以会测试完整的流程链,流程链中每一个环节都是真实、具体的。单元测试是单独测试流程链中的某一环,这一个环所直接依赖的下游环节使用模拟的方式来提供支撑,这一技术称为Mock。在介绍单元测试的时候,我们会介绍如何mock依赖对象,并简单对mock的原理进行介绍。
本文所关注的另一个主题,是在持久层测试时,如何消除修改数据库的副作用。
集成测试
集成测试是在所有组件都已经开发完成之后,进行组装测试。有两种测试方式:启动web容器进行测试,使用模拟环境测试。这两种测试的效果没有什么差别,只是使用模拟环境测试的话,可以不用启动web容器,从而会少一些开销。另外,两者的测试API会有所不同。
启动web容器进行测试
我们通过测试最上层的Controller来实施集成测试,我们的测试目标如下:
@RestController
public class CityController {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> CityService cityService;
<span class="hljs-meta">@GetMapping</span>(<span class="hljs-string">"/cities"</span>)
<span class="hljs-keyword">public</span> ResponseEntity<?> getAllCities() {
List<City> cities = cityService.getAllCities();
<span class="hljs-keyword">return</span> ResponseEntity.ok(cities);
}
}
这是一个Controller,它对外提供一个服务/cities
,返回一个包含所有城市的列表。这个Controller通过调用下一层的CityService来完成自己的职责。
针对这个Controller的集成测试方案如下:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CityControllerWithRunningServer {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> TestRestTemplate restTemplate;
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">getAllCitiesTest</span><span class="hljs-params">()</span> </span>{
String response = restTemplate.getForObject(<span class="hljs-string">"/cities"</span>, String.class);
Assertions.assertThat(response).contains(<span class="hljs-string">"San Francisco"</span>);
}
}
首先我们使用@RunWith(SpringRunner.class)
声明在Spring的环境中进行单元测试,这样Spring的相关注解才会被识别并起效。然后我们使用@SpringBootTest,它会扫描应用程序的spring配置,并构建完整的Spring Context。我们为其参数webEnvironment赋值为SpringBootTest.WebEnvironment.RANDOM_PORT,这样就会启动web容器,并监听一个随机的端口,同时,为我们自动装配一个TestRestTemplate类型的bean来辅助我们发送请求。
使用模拟环境测试
测试的目标不变,测试的方案如下:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class CityControllerWithMockEnvironment {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> MockMvc mockMvc;
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">getAllCities</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception </span>{
mockMvc.perform(MockMvcRequestBuilders.get(<span class="hljs-string">"/cities"</span>))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString(<span class="hljs-string">"San Francisco"</span>)));
}
}
我们依然使用@SpringBootTest
,但是没有设置其webEnvironment
属性,这样依然会构建完整的Spring Context,但是不会再启动web容器。为了进行测试,我们需要使用MockMvc
实例发送请求,而我们使用@AutoConfigureMockMvc
则是因为这样可以获得自动配置的MockMvc
实例。
具体测试的代码中出现很多新的API,对于API细节的研究不在本文计划范围内。
单元测试
上文中描述的两种集成测试的方案,相同的一点是都会构建整个Spring Context。这表示所有声明的bean,而不管声明的方式为何,都会被构建实例,并且都能被依赖。这里隐含的意思是从上到下整条依赖链上的代码都已实现。
Mock技术
在开发的过程中进行测试,无法满足上述的条件,Mock技术可以让我们屏蔽掉下层的依赖,从而专注于当前的测试目标。Mock技术的思想是,当测试目标的下层依赖的行为是可预期的,那么测试目标本身的行为也是可预期的,测试就是把实际的结果和测试目标的预期结果做比较,而Mock就是预先设定下层依赖的行为表现。
Mock的流程
- 将测试目标的依赖对象进行mock,设定其预期的行为表现。
- 对测试目标进行测试。
- 检测测试结果,检查在依赖对象的预期行为下,测试目标的结果是否符合预期。
Mock的使用场景
- 多人协作时,可以通过mock进行无等待的测试先行。
- 当测试目标的依赖对象需要访问外部的服务,而外部服务不易获得时,可以通过mock来模拟服务可用。
- 当在排查不容易复现的问题场景时,通过mock来模拟问题。
测试web层
测试的目标不变,测试的方案如下:
/**
* 不构建整个Spring Context,只构建指定的Controller进行测试。需要对相关的依赖进行mock.<br>
* Created by lijinlong9 on 2018/8/22.
*/
@RunWith(SpringRunner.class)
@WebMvcTest(CityController.class)
public class CityControllerWebLayer {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> MockMvc mvc;
<span class="hljs-meta">@MockBean</span>
<span class="hljs-keyword">private</span> CityService service;
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">getAllCities</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception </span>{
City city = <span class="hljs-keyword">new</span> City();
city.setId(<span class="hljs-number">1L</span>);
city.setName(<span class="hljs-string">"杭州"</span>);
city.setState(<span class="hljs-string">"浙江"</span>);
city.setCountry(<span class="hljs-string">"中国"</span>);
Mockito.when(service.getAllCities()).thenReturn(Collections.singletonList(city));
mvc.perform(MockMvcRequestBuilders.get(<span class="hljs-string">"/cities"</span>))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString(<span class="hljs-string">"杭州"</span>)));
}
}
这里不再使用@SpringBootTest
,而代之以@WebMvcTest
,这样只会构建web层或者指定的一到多个Controller的bean。@WebMvcTest
同样可以为我们自动配置MockMvc
类型的bean,我们可以使用它来模拟发送请求。
@MockBean
是一个新接触的注解,它表示对应的bean是一个模拟的bean。因为我们要测试CityController
,对其依赖的CityService
,我们需要mock其预期的行为表现。在具体的测试方法中,使用Mockito的API对sercive的行为进行mock,它表示当调用service的getAllCities时,会返回预先设定的一个City对象的列表。
之后就是发起请求,并预测结果。
Mockito是Java语言的mock测试框架,spring以自己的方式集成了它。
测试持久层
持久层的测试方案跟具体的持久层技术相关。这里我们介绍基于Mybatis的持久层的测试。
测试目标是:
@Mapper
public interface CityMapper {
<span class="hljs-function">City <span class="hljs-title">selectCityById</span><span class="hljs-params">(<span class="hljs-keyword">int</span> id)</span></span>;
<span class="hljs-function">List<City> <span class="hljs-title">selectAllCities</span><span class="hljs-params">()</span></span>;
<span class="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">insert</span><span class="hljs-params">(City city)</span></span>;
}
测试方案是:
@RunWith(SpringRunner.class)
@MybatisTest
@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
// @Transactional(propagation = Propagation.NOT_SUPPORTED)
public class CityMapperTest {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> CityMapper cityMapper;
<span class="hljs-meta">@Test</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-comment">/*selectCityById*/</span> test1() <span class="hljs-keyword">throws</span> Exception {
City city = cityMapper.selectCityById(<span class="hljs-number">1</span>);
Assertions.assertThat(city.getId()).isEqualTo(Long.valueOf(<span class="hljs-number">1</span>));
Assertions.assertThat(city.getName()).isEqualTo(<span class="hljs-string">"San Francisco"</span>);
Assertions.assertThat(city.getState()).isEqualTo(<span class="hljs-string">"CA"</span>);
Assertions.assertThat(city.getCountry()).isEqualTo(<span class="hljs-string">"US"</span>);
}
<span class="hljs-meta">@Test</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-comment">/*insertCity*/</span> test2() <span class="hljs-keyword">throws</span> Exception {
City city = <span class="hljs-keyword">new</span> City();
city.setId(<span class="hljs-number">2L</span>);
city.setName(<span class="hljs-string">"HangZhou"</span>);
city.setState(<span class="hljs-string">"ZheJiang"</span>);
city.setCountry(<span class="hljs-string">"CN"</span>);
<span class="hljs-keyword">int</span> result = cityMapper.insert(city);
Assertions.assertThat(result).isEqualTo(<span class="hljs-number">1</span>);
}
<span class="hljs-meta">@Test</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-comment">/*selectNewInsertedCity*/</span> test3() <span class="hljs-keyword">throws</span> Exception {
City city = cityMapper.selectCityById(<span class="hljs-number">2</span>);
Assertions.assertThat(city).isNull();
}
}
这里使用了@MybatisTest
,它负责构建mybatis-mapper层的bean,就像上文中使用的@WebMvcTest
负责构建web层的bean一样。值得一提的是@MybatisTest
来自于mybatis-spring-boot-starter-test
项目,它是mybatis团队根据spring的习惯来实现的。Spring原生支持的两种持久层的测试方案是@DataJpaTest
和@JdbcTest
,分别对应JPA持久化方案和JDBC持久化方案。
@FixMethodOrder
来自junit,目的是为了让一个测试类中的多个测试方案按照设定的顺序执行。一般情况下不需要如此,我这里想确认test2方法中插入的数据,在test3中是否还存在,所以需要保证两者的执行顺序。
我们注入了CityMapper
,因为其没有更底层的依赖,所以我们不需要进行mock。
@MybatisTest
除了实例化mapper相关的bean之外,还会检测依赖中的内嵌数据库,然后测试的时候使用内嵌数据库。如果依赖中没有内嵌数据库,就会失败。当然,使用内嵌数据库是默认的行为,可以使用配置进行修改。
@MybatisTest
还会确保每一个测试方法都是事务回滚的,所以在上述的测试用例中,test2插入了数据之后,test3中依然获取不到插入的数据。当然,这也是默认的行为,可以改变。
测试任意的bean
service层并不作为一种特殊的层,所以没有什么注解能表示“只构建service层的bean”这种概念。
这里将介绍另一种通用的测试场景,我要测试的是一个普通的bean,没有什么特殊的角色,比如不是担当特殊处理的controller,也不是负责持久化的dao组件,我们要测试的只是一个普通的bean。
上文中我们使用@SpringBootTest
的默认机制,它去查找@SpringBootApplication
的配置,据此构建Spring的上下文。查看@SpringBootTest
的doc,其中有一句是:
Automatically searches for a @SpringBootConfiguration when nested @Configuration is not used, and no explicit classes are specified.
这表示我们可以通过classes属性来指定Configuration类,或者定义内嵌的Configuration类来改变默认的配置。
在这里我们通过内嵌的Configuration类来实现,先看下测试目标 - CityService:
@Service
public class CityService {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> CityMapper cityMapper;
<span class="hljs-function"><span class="hljs-keyword">public</span> List<City> <span class="hljs-title">getAllCities</span><span class="hljs-params">()</span> </span>{
<span class="hljs-keyword">return</span> cityMapper.selectAllCities();
}
}
测试方案:
@RunWith(SpringRunner.class)
@SpringBootTest
public class CityServiceTest {
<span class="hljs-meta">@Configuration</span>
<span class="hljs-keyword">static</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CityServiceConfig</span> </span>{
<span class="hljs-meta">@Bean</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> CityService <span class="hljs-title">cityService</span><span class="hljs-params">()</span> </span>{
<span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> CityService();
}
}
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> CityService cityService;
<span class="hljs-meta">@MockBean</span>
<span class="hljs-keyword">private</span> CityMapper cityMapper;
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">getAllCities</span><span class="hljs-params">()</span> </span>{
City city = <span class="hljs-keyword">new</span> City();
city.setId(<span class="hljs-number">1L</span>);
city.setName(<span class="hljs-string">"杭州"</span>);
city.setState(<span class="hljs-string">"浙江"</span>);
city.setCountry(<span class="hljs-string">"CN"</span>);
Mockito.when(cityMapper.selectAllCities())
.thenReturn(Collections.singletonList(city));
List<City> result = cityService.getAllCities();
Assertions.assertThat(result.size()).isEqualTo(<span class="hljs-number">1</span>);
Assertions.assertThat(result.get(<span class="hljs-number">0</span>).getName()).isEqualTo(<span class="hljs-string">"杭州"</span>);
}
}
同样的,对于测试目标的依赖,我们需要进行mock。
Mock操作
单元测试中,需要对测试目标的依赖进行mock,这里有必要对mock的细节介绍下。上文单元测试部分已对Mock的逻辑、流程和使用场景进行了介绍,此处专注于实践层面进行说明。
根据方法参数设定预期行为
一般的mock是对方法级别的mock,在方法有入参的情况下,方法的行为可能会跟方法的具体参数值有关。比如一个除法的方法,传入参数4、2得结果2,传入参数8、2得结果4,传入参数2、0得异常。
mock可以针对不同的参数值设定不同的预期,如下所示:
@RunWith(SpringRunner.class)
@SpringBootTest
public class MathServiceTest {
<span class="hljs-meta">@Configuration</span>
<span class="hljs-keyword">static</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ConfigTest</span> </span>{}
<span class="hljs-meta">@MockBean</span>
<span class="hljs-keyword">private</span> MathService mathService;
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">testDivide</span><span class="hljs-params">()</span> </span>{
Mockito.when(mathService.divide(<span class="hljs-number">4</span>, <span class="hljs-number">2</span>))
.thenReturn(<span class="hljs-number">2</span>);
Mockito.when(mathService.divide(<span class="hljs-number">8</span>, <span class="hljs-number">2</span>))
.thenReturn(<span class="hljs-number">4</span>);
Mockito.when(mathService.divide(ArgumentMatchers.anyInt(), ArgumentMatchers.eq(<span class="hljs-number">0</span>))) <span class="hljs-comment">// 必须同时用matchers语法</span>
.thenThrow(<span class="hljs-keyword">new</span> RuntimeException(<span class="hljs-string">"error"</span>));
Assertions.assertThat(mathService.divide(<span class="hljs-number">4</span>, <span class="hljs-number">2</span>))
.isEqualTo(<span class="hljs-number">2</span>);
Assertions.assertThat(mathService.divide(<span class="hljs-number">8</span>, <span class="hljs-number">2</span>))
.isEqualTo(<span class="hljs-number">4</span>);
Assertions.assertThatExceptionOfType(RuntimeException.class)
.isThrownBy(() -> {
mathService.divide(<span class="hljs-number">3</span>, <span class="hljs-number">0</span>);
})
.withMessageContaining(<span class="hljs-string">"error"</span>);
}
}
上面的测试可能有些奇怪,mock的对象也同时作为测试的目标。这是因为我们的目的在于介绍mock,所以简化了测试流程。
从上述测试用例可以看出,我们除了可以指定具体参数时的行为,也可以指定参数满足一定匹配规则时的行为。
有返回的方法
对于有返回的方法,mock时可以设定的行为有:
返回设定的结果,如:
when(taskService.findResourcePool(any()))
.thenReturn(resourcePool);
直接抛出异常,如:
when(taskService.createTask(any(), any(), any()))
.thenThrow(new RuntimeException("zz"));
实际调用真实的方法,如:
when(taskService.createTask(any(), any(), any()))
.thenCallRealMethod();
注意,调用真实的方法有违mock的本义,应该尽量避免。如果要调用的方法中调用了其他的依赖,需要自行注入其他的依赖,否则会空指针。
无返回的方法
对于无返回的方法,mock时可以设定的行为有:
直接抛出异常,如:
doThrow(new RuntimeException("test"))
.when(taskService).saveToDBAndSubmitToQueue(any());
实际调用(下列为Mockito类的doc中给出的示例,我并没有遇到此需求),如:
doAnswer(new Answer() {
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
Mock mock = invocation.getMock();
return null;
}})
.when(mock).someMethod();
附录
相关注解的汇总
@RunWith
:
junit的注解,通过这个注解使用SpringRunner.class
,能够将junit和spring进行集成。后续的spring相关注解才会起效。@SpringBootTest
:
spring的注解,通过扫描应用程序中的配置来构建测试用的Spring上下文。@AutoConfigureMockMvc
:
spring的注解,能够自动配置MockMvc
对象实例,用来在模拟测试环境中发送http请求。@WebMvcTest
:
spring的注解,切片测试的一种。使之替换@SpringBootTest
能将构建bean的范围限定于web层,但是web层的下层依赖bean,需要通过mock来模拟。也可以通过参数指定只实例化web层的某一个到多个controller。具体可参考Auto-configured Spring MVC Tests。@RestClientTest
:
spring的注解,切片测试的一种。如果应用程序作为客户端访问其他Rest服务,可以通过这个注解来测试客户端的功能。具体参考Auto-configured REST Clients。@MybatisTest
:
mybatis按照spring的习惯开发的注解,切片测试的一种。使之替换@SpringBootTest
,能够将构建bean的返回限定于mybatis-mapper层。具体可参考mybatis-spring-boot-test-autoconfigure。@JdbcTest
:
spring的注解,切片测试的一种。如果应用程序中使用Jdbc作为持久层(spring的JdbcTemplate
),那么可以使用该注解代替@SpringBootTest
,限定bean的构建范围。官方参考资料有限,可自行网上查找资料。@DataJpaTest
:
spring的注解,切片测试的一种。如果使用Jpa作为持久层技术,可以使用这个注解,参考Auto-configured Data JPA Tests。@DataRedisTest
:
spring的注解,切片测试的一种。具体内容参考Auto-configured Data Redis Tests。
设置测试数据库
给持久层测试类添加注解@AutoConfigureTestDatabase(replace = Replace.NONE)
可以使用配置的数据库作为测试数据库。同时,需要在配置文件中配置数据源,如下:
spring:
datasource:
url: jdbc:mysql://127.0.0.1/test
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
事务不回滚
可以在测试方法上添加@Rollback(false)
来设置不回滚,也可以在测试类的级别上添加该注解,表示该类所有的测试方法都不会回滚。