简介
在开发中,发现很多人并不理解什么是单元测试,容易和集成测试混淆,所以专门写一章来讲解,再拓展一下如果获得代码测试覆盖率。我们通常可以将测试分为两大类,一种是集成测试,一种是单元测试。
- 集成测试:对功能的整体测试,要完整依赖功能的所有代码、组件。比如获得城市详情的功能,不论是从界面点击测试、Postman接口测试、启动服务后代码运行接口,实际上都属于集成测试,运行需要依赖服务启动、数据库操作,完整的运行所有代码
- 单元测试:对功能单元的测试,这个单元通常是类的方法,一个功能由一个或多个方法调用完成
单元测试
Maven依赖
以下是示例项目springboot-restful的Maven依赖
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>springboot</groupId>
<artifactId>springboot-restful</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-restful</name>
<!-- Spring Boot 启动父依赖 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
</parent>
<properties>
<mybatis-spring-boot>1.2.0</mybatis-spring-boot>
<mysql-connector>8.0.19</mysql-connector>
</properties>
<dependencies>
<!-- Spring Boot Web 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Mybatis 依赖 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot}</version>
</dependency>
<!-- MySQL 连接驱动依赖 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector}</version>
</dependency>
</dependencies>
</project>
以下是单元测试Maven依赖
<project>
<!-- Spring Boot Test 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.12.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
相关依赖的作用
-
spring-boot-starter-test:MockMvc类需要,用于模拟Http调用测试Controller接口
-
junit:@Before、@Test注解需要,@Before用于在每次测试前执行代码,@Test用于运行单元测试用例,及断言验证
-
mockito-core:@InjectMocks注解、@Mock注解、Mockito类使用,用于Mock调用及断言验证
示例业务
CityDao用于访问数据库操作
public interface CityDao {
List<City> findAllCity();
City findById(@Param("id") Long id);
Long saveCity(City city);
Long updateCity(City city);
Long deleteCity(Long id);
}
CityService用于定义业务逻辑接口
public interface CityService {
/**
* 获取城市信息列表
*/
List<City> findAllCity();
/**
* 根据城市 ID,查询城市信息
*/
City findCityById(Long id);
/**
* 新增城市信息
*/
Long saveCity(City city);
/**
* 更新城市信息
*/
Long updateCity(City city);
/**
* 根据城市 ID,删除城市信息
*/
Long deleteCity(Long id);
}
CItyServiceImpl用于实现业务逻辑接口
@Service
public class CityServiceImpl implements CityService {
@Autowired
private CityDao cityDao;
@Override
public List<City> findAllCity(){
return cityDao.findAllCity();
}
@Override
public City findCityById(Long id) {
City city = cityDao.findById(id);
if(city == null) {
City defaultCity = new City();
defaultCity.setId(0L);
defaultCity.setProvinceId(0L);
defaultCity.setCityName("默认城市");
defaultCity.setDescription("默认城市");
return defaultCity;
}else {
if(city.getId() < 0) {
City defaultCity = new City();
defaultCity.setId(-1L);
defaultCity.setProvinceId(-1L);
defaultCity.setCityName("省份");
defaultCity.setDescription("省份");
return defaultCity;
}
}
return city;
}
@Override
public Long saveCity(City city) {
return cityDao.saveCity(city);
}
@Override
public Long updateCity(City city) {
return cityDao.updateCity(city);
}
@Override
public Long deleteCity(Long id) {
return cityDao.deleteCity(id);
}
}
CityRestController用于提供对外接口
@RestController
public class CityRestController {
@Autowired
private CityService cityService;
@RequestMapping(value = "/api/city/{id}", method = RequestMethod.GET)
public City findOneCity(@PathVariable("id") Long id) {
return cityService.findCityById(id);
}
@RequestMapping(value = "/api/city", method = RequestMethod.GET)
public List<City> findAllCity() {
return cityService.findAllCity();
}
@RequestMapping(value = "/api/city", method = RequestMethod.POST)
public void createCity(@RequestBody City city) {
cityService.saveCity(city);
}
@RequestMapping(value = "/api/city", method = RequestMethod.PUT)
public void modifyCity(@RequestBody City city) {
cityService.updateCity(city);
}
@RequestMapping(value = "/api/city/{id}", method = RequestMethod.DELETE)
public void modifyCity(@PathVariable("id") Long id) {
cityService.deleteCity(id);
}
}
Service单元测试
建议将以下类方法静态引入,这样可以简化写法,不需要指明那个类
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;
以下为CityService类的单元测试,通过Mockito、junit不需要启动整个应用进行快速测试,将结合代码逐行讲解
public class CityServiceTest {
/**
* @InjectMocks属于mockito-core包
* @InjectMocks注解用于标识被测试类,会将@Mock、@Spy注入到被测试类中
* 如果标识的是一个类,则会创建该类的实例;如果被标识的是一个接口,需要手动new该接口的实现
**/
@InjectMocks
private CityService componentService = new CityServiceImpl();
/**
* @Mock属于mockito-core包
* @Mock注解用于标识测试类中被依赖的类,会创建一个该类或接口的代理类,不会调用真实的类代码
* 我们可以应用这种特性,在测试方法时,让被依赖类根据不同参数返回预期的值
**/
@Mock
private CityDao cityDao;
/**
* @Before属于junit包
* @Before注解用于在@Test标识的测试用例运行前执行代码
**/
@Before
public void setUp() {
//MockitoAnnotations类属于mockito-core包
//openMocks(this)作用是根据@InjectMocks、@Mock注解生成测试代理类
//结合@Before注解就是每次运行测试用例之前都会重置测试代理类
MockitoAnnotations.openMocks(this);
}
/**
* @Test属于junit包,用于作为测试用例运行
* 验证根据ID获得城市
* 正常情况
*
* @see org.spring.springboot.service.CityService#findCityById(Long)
*/
@Test
public void findCityById_NormalTest() {
//定义cityDao.findById(10L) Mock返回的数据
City city = new City();
city.setId(10L);
city.setProvinceId(10L);
city.setCityName("正常城市");
city.setDescription("正常介绍");
//设置当执行CityService被测试方法,依赖的cityDao在输入10时
//返回city(id=10,provinceId=10,cityName=正常城市,description=正常介绍)
//这也就是常说的Mock
when(cityDao.findById(10L)).thenReturn(city);
//真实执行测试用例,如果debug会发现findCityById的代码被真实执行了
//而cityDao则生成了一个代理类,返回预期的city实体
City cityResult = componentService.findCityById(10L);
//Assert类属于junit包,设置断言,所有的预期都应该满足
//断言 返回实体不为NULL
Assert.assertNotNull(cityResult);
//断言 返回实体ID为10
Assert.assertEquals(cityResult.getId(), (Long) 10L);
//断言 返回实体名称为正常城市
Assert.assertEquals(cityResult.getCityName(), "正常城市");
//全写是Mockito.verify,由于我们静态引入所以可以简写
//断言 cityDao.findById()方法被调用了1次
verify(cityDao, times(1)).findById(any());
}
/**
* 这是另一个测试用例,用于验证当无法查到数据,返回默认城市的逻辑是否符合预期
* 验证根据ID获得城市为null时
* 返回默认城市
*
* @see org.spring.springboot.service.CityService#findCityById(Long)
*/
@Test
public void findCityById_DefaultTest() {
//设置当执行CityService被测试方法,依赖的cityDao在输入100时
//返回null
//此时根据代码逻辑,会创建一个cityName=默认城市的类,并返回
//这也就是常说的Mock
when(cityDao.findById(100L)).thenReturn(null);
//真实执行测试用例
City cityResult = componentService.findCityById(100L);
//设置断言,所有的预期都应该满足
//断言 返回实体不为NULL
Assert.assertNotNull(cityResult);
//断言 返回实体ID为0
Assert.assertEquals(cityResult.getId(), (Long) 0L);
//断言 返回实体名称为默认城市
Assert.assertEquals(cityResult.getCityName(), "默认城市");
//断言 cityDao.findById()方法被调用了1次
verify(cityDao, times(1)).findById(any());
}
/**
* 这是另一个测试用例,用于验证当id小于0时,返回数据为省份的逻辑是否符合预期
**/
@Test
public void findCityById_LessThan0Test() {
//定义cityDao.findById(-10L) Mock返回的数据
//这里我们只定义了id=-10,因为我们知道代码逻辑预期只对id等于负数进行判断,没有使用其他值,所以可以简化
City city = new City();
city.setId(-10L);
//设置当执行CityService被测试方法,依赖的cityDao在输入100时
//返回city(id=-1,provinceId=-1,cityName=省份,description=省份)
//此时根据代码逻辑,会创建一个cityName=省份的类,并返回
//这也就是常说的Mock
when(cityDao.findById(-10L)).thenReturn(city);
//真实执行测试用例
City cityResult = componentService.findCityById(-10L);
//设置断言,所有的预期都应该满足
//断言 返回实体不为NULL
Assert.assertNotNull(cityResult);
//断言 返回实体ID为-1
Assert.assertEquals((long) cityResult.getId(), -1L);
//断言 返回实体名称为省份
Assert.assertEquals(cityResult.getCityName(), "省份");
//断言 cityDao.findById()方法被调用了1次
verify(cityDao, times(1)).findById(any());
}
}
Controller单元测试
以下为CityRestController类的单元测试,通过Mockito、junit、MockMvc不需要启动整个应用进行快速测试。可以看到这里多使用了MockMvc,这是用于模拟Http调用Controller层代码,会真实的请求到Controller中的代码,将结合代码逐行讲解。
public class CityRestControllerTest {
/**
* @InjectMocks属于mockito-core包
* @InjectMocks注解用于标识被测试类,会将@Mock、@Spy注入到被测试类中
* 如果标识的是一个类,则会创建该类的实例;如果被标识的是一个接口,需要手动new该接口的实现
**/
@InjectMocks
private CityRestController cityRestController;
/**
* @Mock属于mockito-core包
* @Mock注解用于标识测试类中被依赖的类,会创建一个该类或接口的代理类,不会调用真实的类代码
* 我们可以应用这种特性,在测试方法时,让被依赖类根据不同参数返回预期的值
**/
@Mock
private CityService cityService;
/**
* MockMvc属于spring-boot-starter-test包
* MockMvc用于模拟Http请求,它会真实的调用Controller对应方法,并对响应设置断言
**/
private MockMvc mvc;
/**
* @Before属于junit包
* @Before注解用于在@Test标识的测试用例运行前执行代码
**/
@Before
public void setup() {
//MockitoAnnotations类属于mockito-core包
//openMocks(this)作用是根据@InjectMocks、@Mock注解生成测试代理类
//结合@Before注解就是每次运行测试用例之前都会重置测试代理类
MockitoAnnotations.openMocks(this);
//构建cityRestController对象的模拟Http代理类
mvc = MockMvcBuilders.standaloneSetup(cityRestController).build();
}
/**
* 用于验证通过Controller接口,调用CityService正常的情况
* 验证根据ID获得城市逻辑正常
*
* @see org.spring.springboot.controller.CityRestController#findOneCity(Long)
*/
@Test
public void findOneCity_normalTest() throws Exception {
//定义接口的Mock返回的数据
City city = new City();
city.setId(1L);
city.setProvinceId(1L);
city.setCityName("泰安");
city.setDescription("泰山");
//设置当执行CityRestController被测试方法,依赖的cityService在1时
//返回city(id=1,provinceId=1,cityName=泰安,description=泰山)
//这也就是常说的Mock
when(cityService.findCityById(1L)).thenReturn(city);
//执行并设置断言
//GET 请求/api/city/1
//设置json请求
MvcResult mvcResult = mvc.perform(get("/api/city/1")
.contentType(MediaType.APPLICATION_JSON))
//断言 Http响应码是200
.andExpect(status().isOk())
//断言 Http返回body是相同实体
.andExpect(content().json("{\"id\":1,\"provinceId\":1,\"cityName\":\"泰安\",\"description\":\"泰山\"}"))
.andReturn();
}
}
代码覆盖率
我们看很多开源项目时,会看到一个小图标表示代码覆盖率70%,含义是单元测试走过代码行占总有效代行数的百分比,有效代码行是指去除换行等无意义代码。
IDEA代码覆盖率步骤
通过IDEA自带代码覆盖率检测,打开单元测试类,如图所示。
点击Run…with Coverage,如图所示。
在IDEA右侧会显示Coverage框,里面会显示整个项目的代码覆盖率,你可以找到你测试的类,查看每个类的测试覆盖率,以我们刚刚的单元测试为例。CityRestController单测代码覆盖率为22%,如图所示。
CityService单测代码覆盖率为80%,点击可以进一步查看那些地方没有走到,绿色代码单测走过,红色代表未走过,灰色代表无效代码,如图所示。
可以通过不断补充单元测试,提高代码覆盖率。
思考
我们使用发问的形式,在看作者回答前可以自己先思考
通过Mock编写单元测试的优点是什么?
- 可以不依赖于环境重复运行单元测试
- 每一次改动发布,都可以运行之前的所有单测,保障改动对之前功能兼容
- 重构代码的重要保障,《重构改善既有代码的设计》中的核心其实就是面向测试编程,优化代码的实现,但保持结果一致的预期,可以避免优化引入大量BUG
自己造Mock数据又返回自己验证是否是有效测试,如果调用方法不返回这种数据,甚至抛出异常呢?
我们单测的目的是目标方法逻辑代码正确性,通过控制变量的形式,假设被Mock方法始终正常,这种情况下,被测试逻辑代码符合预期,则说明逻辑代码是正确的,而Mock方法的正确性则由另一个单测保障。
这个例子会调用数据库,那数据库操作方法的正确性该如何保障呢?
如果使用Mybatis-plus这种框架,由框架的单测保障。使用Mybatis映射文件,可以通过启动一个H2内存数据库,执行SQL验证正确性。如果是NoSQL,可以考虑通过启动本地容器的方式验证,不过一般情况下优先保障逻辑代码的正确性,数据库出现异常是比较少的情况。
那假设是RPC调用呢,又该如何单元测试?
对于消费者,可以对RPC接口Mock来单元测试。RPC接口内的逻辑正确,由服务提供者的单元测试保障。
示例项目代码
以上讲解知识点的示例项目源码也已上传,可找到项目单元测试直接运行。