单元测试原则
单元测试必须遵循AIR(Automatic, Independent, Repeatable)原则:单元测试在线上运行时,感觉像空气(AIR)一样感觉不到,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。
- Automatic(自动化):单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中禁止使用System.out来进行人肉验证,必须使用assert来验证。
- Independent(独立性):保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
- Repeatable(可重复):单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
Mock工具对比
目前主要的Mock工具主要有Mockito、Spock、PowerMock和JMockit等,基本差异如下:
工具 | 原理 | 最小Mock单元 | 对被Mock方法的限制 | 上手难度 | IDE支持 |
---|---|---|---|---|---|
Mockito | 动态代理 | 类 | 不能Mock私有/静态和构造方法 | 较容易 | 很好 |
Spock | 动态代理 | 类 | 不能Mock私有/静态和构造方法 | 较复杂 | 一般 |
PowerMock | 自定义类加载器 | 类 | 任何方法皆可 | 较复杂 | 较好 |
JMockit | 运行时字节码修改 | 类 | 不能Mock构造方法(new操作符) | 较复杂 | 一般 |
TestableMock | 运行时字节码修改 | 方法 | 任何方法皆可 | 很容易 | 一般 |
本文以TestableMock为例,针对单元测试中常出现的几种场景进行测试。
TestableMock 官方文档:https://alibaba.github.io/testable-mock/#/
TestableMock GitHub地址:https://github.com/alibaba/testable-mock
备注:单元测试基础学习:https://www.yuque.com/atguigu/springboot/ksndgx
本文使用代码样例环境:Spring Boot 2.5.7、TestableMock 0.70、MySQL 5.7.32、MyBatis 2.2.0、H2 1.4.200等,详见pom.xml文件
数据初始化准备
创建数据库goodsdb,创建表并初始化数据:
DROP TABLE IF EXISTS book_t;
CREATE TABLE book_t (
book_id int(11) AUTO_INCREMENT PRIMARY KEY,
book_name varchar(32) NOT NULL,
book_price decimal(5,2) NOT NULL
);
INSERT INTO book_t VALUES (1, '安徒生童话', 99.99);
INSERT INTO book_t VALUES (2, 'MySQL实战教程', 88.88);
创建SpringBoot项目
官方地址:https://start.spring.io/
pom.xml文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.7</version>
<relativePath/>
</parent>
<groupId>com.lwy.it</groupId>
<artifactId>testablemock-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>testablemock-demo</name>
<description>Demo project for Spring Boot With TestableMock</description>
<properties>
<java.version>1.8</java.version>
<testable.version>0.7.0</testable.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 单元测试相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba.testable</groupId>
<artifactId>testable-all</artifactId>
<version>${testable.version}</version>
<scope>test</scope>
</dependency>
<!-- 内存数据库,用于Mock MySQL-->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
-javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar
</argLine>
</configuration>
</plugin>
<!-- 插件介绍:http://maven.apache.org/surefire/maven-surefire-report-plugin/ -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.0.0-M5</version>
<configuration>
<outputDirectory>${basedir}/target/report</outputDirectory>
</configuration>
</plugin>
</plugins>
</build>
</project>
代码结构
本文采用MVC结构:
创建ResultVO对象,用于封装返回结果给前端:
package com.lwy.it;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class ResultVO<T> implements Serializable {
private List<T> data;
private String message;
public ResultVO() {
}
public ResultVO(List<T> data) {
this.data = data;
}
public ResultVO(List<T> data, String message) {
this.data = data;
this.message = message;
}
}
vo对象:BookVO
package com.lwy.it.book.vo;
import lombok.Data;
import java.io.Serializable;
@Data
public class BookVO implements Serializable {
private Integer bookId;
private String bookName;
private Double bookPrice;
}
控制器层controller对象:BookController
package com.lwy.it.book.controller;
import com.lwy.it.ResultVO;
import com.lwy.it.book.service.BookService;
import com.lwy.it.book.vo.BookVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
@RestController
public class BookController {
@Autowired
private BookService bookService;
@GetMapping("/books")
public ResultVO<BookVO> findAllBook() {
return bookService.getAllBook();
}
/**
* 用于模拟远程服务,通过RestTemplate调用
* 接口服务地址:http://localhost:8080/mock/book
*
* @return ResultVO<BookVO>
*/
@GetMapping("/book")
public ResultVO<BookVO> exclusiveBook() {
BookVO bookVO = new BookVO();
bookVO.setBookId(100000);
bookVO.setBookName("专属丛书:十万个为什么");
bookVO.setBookPrice(998d);
ResultVO<BookVO> resultVO = new ResultVO<>(Arrays.asList(bookVO));
resultVO.setMessage("获取专属丛书成功");
return resultVO;
}
}
服务层service对象:BookService
package com.lwy.it.book.service;
import com.lwy.it.ResultVO;
import com.lwy.it.book.configuration.BookConfiguration;
import com.lwy.it.book.dao.BookDao;
import com.lwy.it.book.vo.BookVO;
import com.lwy.it.utils.DataUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
public class BookService {
@Autowired
private BookDao bookDao;
@Autowired
private BookConfiguration bookConfiguration;
@Autowired
private RestTemplate restTemplate;
public ResultVO<BookVO> getAllBook() {
List<BookVO> list = bookDao.getAllBook();
ResultVO<BookVO> resultVO = DataUtils.convert(list);
// 调用私有方法
ResultVO<BookVO> vo = this.getExclusiveBook();
log.info("获取到远程服务结果为:{}", vo);
return resultVO;
}
// 用于模拟远程服务接口获取数据过程
private ResultVO<BookVO> getExclusiveBook() {
ResultVO<BookVO> resultVO = null;
String url = bookConfiguration.getServerUrl();
log.info("获取到的配置服务URL地址为:{}", url);
// 返回结果是泛型类型,使用ParameterizedTypeReference进行包装
ParameterizedTypeReference<ResultVO<BookVO>> reference = new ParameterizedTypeReference<ResultVO<BookVO>>() {
};
ResponseEntity<ResultVO<BookVO>> responseEntity = restTemplate.exchange(url, HttpMethod.GET, null, reference);
if (Objects.equals(responseEntity.getStatusCode(), HttpStatus.OK)) {
resultVO = responseEntity.getBody();
log.info("获取到的结果为:{}", resultVO);
} else {
log.error("获取数据失败");
}
return resultVO;
}
}
备注:RestTemplate基础学习:https://mp.weixin.qq.com/s/jIZCFOW4iy0j-epKVKQpOQ
数据库持久层dao对象:BookDao
package com.lwy.it.book.dao;
import com.lwy.it.book.vo.BookVO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface BookDao {
List<BookVO> getAllBook();
}
对应mapper文件:book.mysql.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lwy.it.book.dao.BookDao">
<select id="getAllBook" resultType="com.lwy.it.book.vo.BookVO">
SELECT
book_id AS bookId,
book_name AS bookName,
book_price AS bookPrice
FROM
book_t
</select>
</mapper>
相关配置信息
application.properties
server.port=8080
server.servlet.context-path=/mock
#MyBatis配置
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.mapper-locations=classpath:mapper/*.xml
#MySQL配置
spring.datasource.url=jdbc:mysql://localhost:3306/goodsdb?allowMultiQueries=true&serverTimezone=GMT&characterEncoding=utf8
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=123456
#配置远程服务地址
book.server.url=${bookUrl}
创建配置类,用于接收book.server.url变量参数,BookConfiguration
package com.lwy.it.book.configuration;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Data
@Configuration
public class BookConfiguration {
@Value("${book.server.url}")
private String serverUrl;
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}
工具类:DataUtils
package com.lwy.it.utils;
import com.lwy.it.ResultVO;
import org.springframework.util.CollectionUtils;
import java.util.Collections;
import java.util.List;
public class DataUtils {
/**
* 用于数据包装转换
*
* @param list 泛型结合
* @param <T> 泛型类型
* @return ResultVO<T>
*/
public static <T> ResultVO<T> convert(List<T> list) {
ResultVO<T> resultVO = new ResultVO<>();
if (CollectionUtils.isEmpty(list)) {
resultVO.setData(Collections.emptyList());
resultVO.setMessage("数据为空");
} else {
resultVO.setData(list);
resultVO.setMessage("数据不为空");
}
return resultVO;
}
}
以上为服务的相关代码,均在src/main目录下面的目录中。
启动服务(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book):访问http://localhost:8080/mock/books,得到数据库中数据:
{"data":[{"bookId":1,"bookName":"安徒生童话","bookPrice":99.99},{"bookId":2,"bookName":"MySQL实战教程","bookPrice":88.88}],"message":"数据不为空"}
编写单元测试代码
单元测试文件均位于在src/test目录下:
数据持久层单元测试
对于Dao层(数据库相关的查询,更新,删除等)操作,使用嵌入式内存数据库H2 Database验证逻辑。和数据库相关的单元测试,不给数据库造成脏数据。不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。 反例:删除某一行数据的单元测试,在数据库中,先直接手动增加一行作为删除目标,但是这一行新增数据并不符合业务插入规则,导致测试结果异常。
针对BookDao访问数据库进行单元测试,需要在src/test/resources目录下新建application.properties文件,内容如下
server.port=8080
server.servlet.context-path=/mock
#MyBatis配置
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.mapper-locations=classpath:mapper/*.xml
########################数据更改###########################
#内存数据库H2以MYSQL模式运行,初始化时执行classpath:initdb.sql脚本,详情参考:http://www.h2database.com/html/features.html
spring.datasource.url=jdbc:h2:mem:goodsdb;MODE=MYSQL;INIT=RUNSCRIPT FROM 'classpath:initdb.sql'
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=
spring.datasource.password=
#########################数据更改##########################
#自定义变量配置
book.server.url=${bookUrl}
H2数据库初始化脚本initdb.sql文件如下:
DROP TABLE IF EXISTS book_t;
CREATE TABLE book_t (
book_id int(11) AUTO_INCREMENT PRIMARY KEY,
book_name varchar(32) NOT NULL,
book_price decimal(5,2) NOT NULL,
PRIMARY KEY (book_id)
);
INSERT INTO book_t VALUES (1, '测试数据1', 99.99);
INSERT INTO book_t VALUES (2, '测试数据2', 88.88);
INSERT INTO book_t VALUES (3, '测试数据2', 77.77);
INSERT INTO book_t VALUES (4, '测试数据2', 66.66);
INSERT INTO book_t VALUES (5, '测试数据2', 55.55);
INSERT INTO book_t VALUES (6, '测试数据2', 44.44);
BookDao对应单元测试BookDaoTest代码如下(包路径一致):
package com.lwy.it.book.dao;
import com.lwy.it.book.vo.BookVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Slf4j
@SpringBootTest
public class BookDaoTest {
@Autowired
private BookDao bookDao;
@Test
@DisplayName("getAllBook")
public void getAllBook_test() {
List<BookVO> list = bookDao.getAllBook();
log.info("结果为:{}", list);
assertEquals(6, list.size());
}
}
启动测试方法(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),日志如下,执行成功。我们发现数据被H2数据库中我们初始化的数据进行了替代。
2021-12-01 16:25:31.458 INFO 21644 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2021-12-01 16:25:31.919 INFO 21644 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2021-12-01 16:25:31.983 INFO 21644 --- [ main] com.lwy.it.book.dao.BookDaoTest : 结果为:[BookVO(bookId=1, bookName=测试数据1, bookPrice=99.99), BookVO(bookId=2, bookName=测试数据2, bookPrice=88.88), BookVO(bookId=3, bookName=测试数据2, bookPrice=77.77), BookVO(bookId=4, bookName=测试数据2, bookPrice=66.66), BookVO(bookId=5, bookName=测试数据2, bookPrice=55.55), BookVO(bookId=6, bookName=测试数据2, bookPrice=44.44)]
服务层单元测试
因为前面场景服务层依赖外部服务等,测试案例应不依赖环境,所以我们需要把依赖Mock掉
我们使用TestableMock工具,按照官方文档进行操作:https://alibaba.github.io/testable-mock/#/
备注:这里的版本我们使用0.7.x版本,<testable.version>0.7.0</testable.version>,对应泛型Mock更加便捷。
单元测试代码如下:
package com.lwy.it.book.service;
import com.alibaba.testable.core.annotation.MockInvoke;
import com.alibaba.testable.core.tool.PrivateAccessor;
import com.lwy.it.ResultVO;
import com.lwy.it.book.dao.BookDao;
import com.lwy.it.book.vo.BookVO;
import com.lwy.it.utils.DataUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@Slf4j
@SpringBootTest
public class BookServiceTest {
@Autowired
private BookService bookService;
// 前置步骤,准备Mock容器
public static class Mock {
// 放置Mock方法的地方,按照官方示例 把Mock方法访问控制都改成private
/**
* 静态方法&泛型类型方法进行Mock
* DataUtils类中public static <T> ResultVO<T> convert(List<T> list)
*
* @param list 泛型集合
* @param <T> 泛型类型
* @return ResultVO<T>
*/
@MockInvoke(targetClass = DataUtils.class)
private static <T> ResultVO<T> convert(List<T> list) {
log.info("~~~~~~泛型&静态方法被执行~~~~~~");
return new ResultVO<>(Collections.emptyList());
}
/**
* Mock掉数据访问层BookDao的List<BookVO> getAllBook()方法
*
* @return List<BookVO>
*/
@MockInvoke(targetClass = BookDao.class)
private List<BookVO> getAllBook() {
BookVO bookVO = new BookVO();
bookVO.setBookId(10000);
bookVO.setBookName("测试数据");
bookVO.setBookPrice(99.99d);
log.info("~~~~~~Dao层访问被Mock替代~~~~~~");
return new ArrayList<BookVO>() {{
add(bookVO);
}};
}
/**
* BookService getExclusiveBook()方法中调用RestTemplate的方法,外部依赖需要Mock掉
* public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity, ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException
*
* @param url 访问url
* @param method get/post访问方式等
* @param requestEntity 请求参数
* @param responseType 返回类型(因为涉及泛型)
* @param uriVariables
* @param <T> 泛型类型
* @return
* @throws RestClientException
*/
@MockInvoke(targetClass = RestTemplate.class)
private <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity,
ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException {
log.info("~~~~~~调用RestTemplate方法被Mock替代~~~~~~");
return new ResponseEntity<T>(HttpStatus.OK);
}
}
@Test
@DisplayName("public方法:getAllBook单元测试")
public void getAllBook_test() {
ResultVO<BookVO> resultVO = bookService.getAllBook();
log.info("ResultVO结果为:{}", resultVO);
assertEquals(0, resultVO.getData().size());
}
/**
* 对私有方法进行单元测试
*/
@Test
@DisplayName("private方法:getExclusiveBook单元测试")
public void getExclusiveBook_test() {
ResultVO<BookVO> resultVO = PrivateAccessor.invoke(bookService, "getExclusiveBook");
log.info("ResultVO结果为:{}", resultVO);
assertNull(resultVO);
}
}
执行验证逻辑:
执行getAllBook_test测试用例(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),结果如下,实际调用过程中被替代
2021-12-01 16:56:32.623 INFO 23380 --- [ main] com.lwy.it.book.service.BookServiceTest : ~~~~~~Dao层访问被Mock替代~~~~~~
2021-12-01 16:56:32.625 INFO 23380 --- [ main] com.lwy.it.book.service.BookServiceTest : ~~~~~~泛型&静态方法被执行~~~~~~
2021-12-01 16:56:32.625 INFO 23380 --- [ main] com.lwy.it.book.service.BookService : 获取到的配置服务URL地址为:http://localhost:8080/mock/book
2021-12-01 16:56:32.628 INFO 23380 --- [ main] com.lwy.it.book.service.BookServiceTest : ~~~~~~调用RestTemplate方法被Mock替代~~~~~~
2021-12-01 16:56:32.638 INFO 23380 --- [ main] com.lwy.it.book.service.BookService : 获取到的结果为:null
2021-12-01 16:56:32.638 INFO 23380 --- [ main] com.lwy.it.book.service.BookService : 获取到远程服务结果为:null
2021-12-01 16:56:32.638 INFO 23380 --- [ main] com.lwy.it.book.service.BookServiceTest : ResultVO结果为:ResultVO(data=[], message=null)
执行getExclusiveBook_test测试用例(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),结果如下,实际调用过程中被替代
2021-12-01 17:08:10.378 INFO 7740 --- [ main] com.lwy.it.book.service.BookService : 获取到的配置服务URL地址为:http://localhost:8080/mock/book
2021-12-01 17:08:10.383 INFO 7740 --- [ main] com.lwy.it.book.service.BookServiceTest : ~~~~~~调用RestTemplate方法被Mock替代~~~~~~
2021-12-01 17:08:10.394 INFO 7740 --- [ main] com.lwy.it.book.service.BookService : 获取到的结果为:null
2021-12-01 17:08:10.394 INFO 7740 --- [ main] com.lwy.it.book.service.BookServiceTest : ResultVO结果为:null
执行所有测试用例:
mvn clean test -DbookUrl=http://localhost:8080/mock/book
若涉及多profile文件,则添加-P参数
执行所有测试用例并生成报告:
mvn clean surefire-report:report -DbookUrl=http://localhost:8080/mock/book
在target/report/(pom.xml文件配置的${basedir}/target/report)目录下生成了surefire-report.html文件,用浏览器打开即可。
如果需要覆盖率信息,则需要使用Jacoco。