单元测试痛点
1.开发人员在编写单元测试时,不免会与数据库进行数据交互,有查询,新增,删除,修改等操作,除查询之外的操作都会造成数据库脏数据的产生,会影响后续的测试,经年累月之后,不得不重新导入一份完整的数据以继续供测试时使用;
2.另外在开发分布式项目,也会经常与远程服务进行交互,当远程服务宕机时,则会影响测试进度;如果A服务功能需要依赖B服务,两个服务都处于开发中,如果A提前开发完,进行测试时,由于B服务还处于开发中则无法调用B服务;当远程服务都正常运行,在服务之间调用时,又会不免产生大量的脏数据,如果想处理脏数据,又需要去了解远程服务的业务,以及表结构,大大影响效率;
3.测试分支过多,需要编写多种测试用例,工作繁琐,往往工作量是编写接口的好几倍。
解决方式
业务代码
省了mapper层和controller层及一些实体类代码,有需要可以到代码参考下载项目进行参考
public interface CategoryService {
void deleteById(Long id);
Category findById(Long id);
Long save(Category category);
String getTypeDesc(Integer type);
}
@Service
@AllArgsConstructor
public class CategoryServiceImpl implements CategoryService {
private final CategoryDao categoryDao;
private final RemoteRpc remoteRpc;
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteById(Long id) {
categoryDao.deleteById(id);
}
@Override
public Category findById(Long id) {
return categoryDao.findById(id);
}
@Override
public Long save(Category category) {
categoryDao.save(category);
return category.getId();
}
@Override
public String getTypeDesc(Integer type) {
if (type==1){
return "ONE";
}else if (type==2){
return "TWO";
}else if (type==3){
return "THREE";
}else {
return "OTHER";
}
}
}
public interface SnacksService {
String delete(Long id);
}
@Service
@Slf4j
@AllArgsConstructor
public class SnacksServiceImpl implements SnacksService {
private final RemoteRpc remoteRpc;
@Override
public String delete(Long id) {
log.info("删除成功,开始调用远程服务");
return remoteRpc.invork(id.toString());
}
}
@Component
@Slf4j
public class RemoteRpc {
public String invork(String param){
log.info("远程服务调用:{}",param);
return "SUCCESS";
}
}
内存数据库
不产生脏数据的方式就是在根源上杜绝与数据库产生交互,使用内存数据库是一个比较有效的途径,这边采用的是H2数据库,在单元测试启动时,会根据我们指定的建表语句和需要插入数据的sql文件来创建内存数据库,之后的数据交互遍便是与内存数据库进行。
- 项目结构
- pom
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3.2</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
- yml
spring:
datasource:
url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MySQL
username:
password:
driver-class-name: org.h2.Driver
# 指定数据源
data: classpath:data.sql
# 指定需要建表语句
schema: classpath:schema.sql
mybatis-plus:
configuration:
# 控制台打印sql
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
- 单元测试基类
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
//指定启动时加载的配置文件
@ActiveProfiles("test")
@SpringBootTest
public class BaseTest {
}
- 业务测试类
import com.pdl.memory_database.domain.Category;
import com.pdl.memory_database.service.CategoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Date;
public class CategoryTest extends BaseTest {
@Autowired
private CategoryService categoryService;
/*
实际删除的为内存数据库的数据,并不会删除原数据库的数据
*/
@Test
void delete() {
categoryService.deleteById(1L);
}
}
使用junit进行多分支测试
当我们编写的接口需要验证多种情况下的返回结果,可以使用junit框架内部的两个注解:@ParameterizedTest和@CsvSource,通过注解的方法,构造不同情况下的入参,以及不同入参下返回的期望结果值。其中注解@CsvSource中的value值会映射到定义好的入参中。如果入参为对象时,则需要通过实现ArgumentsAggregator类下的方法aggregateArguments,指定@CsvSource中的value转为对应的对象,并在方法入参中进行指定
- @CsvSource参数转换为对象
import com.pdl.memory_database.domain.Category;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregationException;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;
public class CategoryArguments implements ArgumentsAggregator {
@Override
public Object aggregateArguments(ArgumentsAccessor argumentsAccessor, ParameterContext parameterContext)
throws ArgumentsAggregationException {
Category category = new Category();
category.setName(argumentsAccessor.getString(1));
category.setStatus(argumentsAccessor.getInteger(2));
category.setIsDelete(argumentsAccessor.getInteger(3));
return category;
}
}
- 单元测试
public class MultiBranchTest extends BaseTest {
@Autowired
private CategoryService categoryService;
@ParameterizedTest
@CsvSource(value = {
"1,ONE", //场景1
"2,TWO", //场景2
"3,THREE", //场景3
"4,OTHER" //场景4
})
void getTypeDesc(Integer type, String expectation) {
String typeDesc = categoryService.getTypeDesc(type);
Assertions.assertEquals(expectation, typeDesc);
}
@ParameterizedTest
@CsvSource(value = {
"2,蒙牛,1,0", //场景1
"3,伊利,1,0" //场景2
})
void saveCategory(Long expectId, @AggregateWith(CategoryArguments.class) Category category) {
Long id = categoryService.save(category);
Assertions.assertEquals(id, expectId);
}
}
使用spock框架进行多分支测试
Spockk是一个Java和Groovy应用的测试和规范框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合Groovy动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。以下为spock的标签及其作用
given
:输入条件(前置参数)。when
:执行行为(Mock
接口、真实调用)。and
:衔接上个标签,补充的作用。then
:输出条件(验证结果)。with
:配合then
进行使用,对结果值进行校验。-
where
:通过表格的方式来测试多种分支。- pom
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.3-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.3-RC1-groovy-2.4</version>
<scope>test</scope>
</dependency>groovy
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.6</version>
</dependency>
单元测试
import com.pdl.memory_database.domain.Category
import com.pdl.memory_database.rpc.RemoteRpc
import com.pdl.memory_database.service.CategoryService
import com.pdl.memory_database.service.SnacksService
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.test.context.ActiveProfiles
import spock.lang.Specification
import spock.lang.Unroll
/*
使用spock进行单元测试
*/
@ActiveProfiles("test")
@SpringBootTest
class SpockControllerTest extends Specification {
@Autowired
private CategoryService categoryService;
@MockBean
private RemoteRpc remoteRpc;
@Autowired
private SnacksService snacksService;
@Unroll
def "查询数据"() {
when: "查询信息"
def category = categoryService.findById(1)
then: "结果"
with(category) {
id == 1
name == "油炸食品"
}
}
/*
多分支测试时,返回结果比较单一时,直接用expect校验准确性
*/
@Unroll
def "多分支测试,参数:#type,期望:#typeDesc"() {
expect:
categoryService.getTypeDesc(type) == typeDesc
where: "测试不同分支"
type || typeDesc
1 || "ONE"
2 || "TWO"
3 || "THREE"
}
/*
多分支测试时,返回结果为对象,可以通过then比较对象内部值
*/
@Unroll
def "保存数据: #category,结果:#id"() {
when: "保存数据"
def categoryId = categoryService.save(category)
then: "结果验证"
with(categoryId) {
categoryId == id
}
where: "参数"
category || id
new Category(null, "蒙牛酸奶", 1, 0, new Date(), new Date()) || 2
new Category(null, "伊利酸奶", 1, 0, new Date(), new Date()) || 3
}
/*
多分支测试时,需要mock时
*/
@Unroll
def "结合mock进行多分支测试,参数:#id,结果:#rpcResult"() {
when:"调用远程"
Mockito.when(remoteRpc.invork(id.toString())).thenReturn(rpcResult)
def result = snacksService.delete(id)
then: "结果验证"
with(result) {
result == rpcResult
}
where: "参数"
id || rpcResult
1 || "SUCCESS"
2 || "FAILED"
}
}
Junit和spock进行单元测试对比
junit | spock | |
---|---|---|
语法 | 通过Java语言进行编写 | 创建的测试类为Groovy class,开发语言与Java有些许不同,有特定的语法和格式,有部分写法与java完全相同,如方法调用,mock调用等,简单易学易上手 |
代码可读性 | 可读性依赖于代码编写的好坏 | 可通过中文对方法进行定义解释,执行步骤一目了然,可读性很高 |
多分支测试 | 当入参为对象时,需要先实现ArgumentsAggregator类定义参数转化的类,如果@CsvSource中的value内参数顺序有进行调换时,只需要修改参数转化的方法,且一个对象对应一个转化类,较为繁琐,不容易维护 | 可以直接通过where标签里定义不同的参数,直接new出来,无需创建转换类,更加方便,易于管理 |
解决远程服务调用
Mockito是mocking框架,它让你用简洁的API做测试。通过调用提供的API在执行对应方法前定义我们期望的结果,当执行到需要mock的方法时,则会返回我们需要的结果值,而不用去调用内部的业务逻辑。它不仅适用于远程服务调用,也适用于数据库调用,及内部方法调用。
- pom
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
- 需要mock的方法
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class RemoteRpc {
public String invork(){
log.info("远程服务调用");
return "SUCCESS";
}
}
- 单元测试
import com.pdl.memory_database.rpc.RemoteRpc;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;
public class RemoteRpcTest extends BaseTest{
@MockBean
private RemoteRpc remoteRpc;
@Test
void invork(){
String result = "FAIL";
Mockito.when(remoteRpc.invork()).thenReturn(result);
String invork = remoteRpc.invork();
Assertions.assertEquals(result,invork);
}
}
测试方案(建议)
使用内存数据库+spock框架+mock