简介
介绍基于SpringBoot、Junit5、Assertj、Mockito的单元测试使用方式,主要包括框架的使用、快速启动单元测试、mockiot的使用、断言使用和对mybatis plus 进行单元测试的使用。
SpringBoot & Junit5
pom
spring boot 最新版已使用junit5,无需单独引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.22.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>4.3.1</version>
<scope>test</scope>
</dependency>
快速启动
原生springboot创建时会默认创建一个单元测试案例, 因为SpringBootTest注解会将整个程序运行起来,如果代码多或者是连接数据库的话,SpringBootTest启动速度会很慢,不满足单元测试的快速测试要求
@SpringBootTest
class JunittestdemoApplicationTests {
@Resource
DemoAService demoAService;
@Resource
DemoBService demoBService;
@Test
void contextLoads() {
assertThat(demoAService).isNotNull();
assertThat(demoBService).isNotNull();
}
}
使用SpringJunitConfig快速运行单元测试
@SpringJUnitConfig(classes = {
DemoAService.class
})
public class DemoAServiceTest {
@Resource
DemoAService demoAService;
@Autowired(required = false)
DemoBService demoBService;
@Test
public void test(){
assertThat(demoAService).isNotNull();
assertThat(demoBService).isNull();
}
}
通过上面的两个例子也可以发现,SpringJunitConfig 和SpringBootTest相比,SpringJunitConfig可以只指定需要加载到Spring容器中的类,单个测试中用不到的类,不会加载到spring容器中。
Mockito
Mockito 是一款 Java 单元测试 Mock 框架,可以解决SpringBoot使用。mockito-inline可以mock静态方法。 Mock的本质是让我们写更加稳定的单元测试,隔离功能、时间、环境、数据等因素对单元测试的影响,使结果变的可预测,做到真正的"单元"测试。
mock类或接口
设定DemoAService依赖DemoCService,代码如下
@Service
public class DemoAService {
private final DemoCService cService;
public DemoAService(DemoCService cService){
this.cService= cService;
}
public String hello(){
String rest = cService.add();
return "A:"+rest;
}
}
@Service
public class DemoCService {
public String add(){
return "add";
}
}
当对DemoAService#hello方法进行测试时,需要mock掉DemoCService实例,因为单元测试只测试一个方法,至于DemoCService里的add方法,我们只关注add方法返回结果是固定内容,来测试hello方法。
如果结合SpringJunitConfig对DemoCService进行mock呢?使用MockBean注解
package com.example.junittestdemo;
import com.example.junittestdemo.service.DemoAService;
import com.example.junittestdemo.service.DemoCService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import javax.annotation.Resource;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(classes = {
DemoAService.class
})
public class DemoAServiceMockTest {
@Resource
DemoAService demoAService;
@MockBean
DemoCService demoCService;
@Test
@DisplayName("测试mock依赖")
void testHello() {
// 准备
Mockito.when(demoCService.add()).thenReturn("mock c");
// 执行
String hello = demoAService.hello();
// 验证
assertThat(hello).isEqualTo("A:mock c");
}
}
mock静态方法
有些业务代码中,会使用到日期时间类,获取当前时间,然会进行条件组合,假设代码如下,DemoService#now返回当前时间,并且拼接固定字符串
public static class DemoService{
public String now(){
LocalDateTime now = LocalDateTime.now();
return "当前时间:"+now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
测试代码如下,使用Mockito.mockStatic方法对静态方法进行mock
@Test
@DisplayName("mock静态方法测试")
public void testNow(){
// 构造数据
LocalDateTime now = LocalDateTime.of(2022, 1, 1,8,30,36);
try (MockedStatic<LocalDateTime> theMock = Mockito.mockStatic(LocalDateTime.class)) {
theMock.when(LocalDateTime::now).thenReturn(now);
// 执行
String res = service.now();
// 验证
assertThat(res).isEqualTo("当前时间:2022-01-01 08:30:36");
}
}
完整代码
package com.example.junittestdemo;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(
classes = MockStaticMethodTest.DemoService.class
)
public class MockStaticMethodTest {
@Resource
DemoService service;
@Test
@DisplayName("mock静态方法测试")
public void testNow(){
// 构造数据
LocalDateTime now = LocalDateTime.of(2022, 1, 1,8,30,36);
try (MockedStatic<LocalDateTime> theMock = Mockito.mockStatic(LocalDateTime.class)) {
theMock.when(LocalDateTime::now).thenReturn(now);
// 执行
String res = service.now();
// 验证
assertThat(res).isEqualTo("当前时间:2022-01-01 08:30:36");
}
}
public static class DemoService{
public String now(){
LocalDateTime now = LocalDateTime.now();
return "当前时间:"+now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
}
void方法验证
void方法无没有返回值,无法使用assertj框架对返回值进行验证,需要通过Mockito#verify进行验证,这个方法可以对目标方法是否被调用到进行验证,有时候可以测试if...else代码分支情况。 DemoService依赖VoidMethodService,并且调用了两个void方法
public interface VoidMethodService{
void sayHello(String name);
void sayHello(NameDto name);
}
public static class NameDto{
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public static class DemoService{
private final VoidMethodService voidMethodService;
public DemoService(VoidMethodService voidMethodService){
this.voidMethodService= voidMethodService;
}
public void say(String name){
this.voidMethodService.sayHello(name);
}
public void say(NameDto name){
this.voidMethodService.sayHello(name);
}
}
测试,针对普通类型的参数,比如String/Integer等,验证时可以使用值进行比较。如果参数是引用类型,则需要使用ArgumentMatchers对参数进行验证
@SpringJUnitConfig(
classes = VoidMethodTest.DemoService.class
)
public class VoidMethodTest {
@Resource
DemoService service;
@MockBean
VoidMethodService methodService;
@Test()
@DisplayName("测试参数是基础类型")
void test(){
service.say("张飞");
Mockito.verify(methodService,Mockito.times(1))
.sayHello("张飞");
}
@Test()
@DisplayName("测试参数是引用类型")
void testBean(){
NameDto dto = new NameDto();
dto.setName("张飞");
service.say(dto);
Mockito.verify(methodService,Mockito.times(1))
.sayHello(ArgumentMatchers.<NameDto>argThat(arg->"张飞".equals(arg.getName())));
}
}
增删改查
单元测试只测试一小块内容,不测试整体业务情况,一般代码会包括Controller/Service/Mapper,由于mapper已经对数据库操作,基本上无法进行单元测试。 有如下代码
@RestController
@RequestMapping("user")
@AllArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("addUser")
public void addUser(@RequestBody UserDto user){
this.userService.add(user);
}
}
@Service
@AllArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
public void add(UserDto user) {
User entity = this.toUserEntity(user);
this.userMapper.add(entity);
}
private User toUserEntity(UserDto user){
User result = new User();
result.setUsername(user.getUsername());
result.setPassword(user.getPassword());
return result;
}
}
public interface UserMapper {
void add(User user);
void update(User user);
}
测试 controller
controller主要测试请求方式、请求地址、请求内容、返回内容、参数校验等内容,不测试业务逻辑
@SpringJUnitWebConfig(
classes = UserController.class
)
@AutoConfigureMockMvc
@EnableWebMvc
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService userService;
@Test
@SneakyThrows
void testAddUser(){
UserDto user = new UserDto();
user.setUsername("haha");
user.setPassword("Zzz111");
ObjectMapper json = new ObjectMapper();
String userJson = json.writeValueAsString(user);
MvcResult mvcResult = mockMvc.perform(post("/user/addUser")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(userJson)
).andReturn();
assertThat(mvcResult.getResponse().getStatus()).isEqualTo(200);
Mockito.verify(userService,Mockito.times(1))
.add(Mockito.any(UserDto.class));
}
}
测试service
service需要测试除了基本业务逻辑,还包括Dto和Entity之间是否正常。 针对像保存、更新、删除这些可能无返回值的mapper方法,需要验证mapper参数是否符合预期
@SpringJUnitConfig(
classes = UserServiceImpl.class
)
public class UserServiceTest {
@Resource
UserService service;
@MockBean
UserMapper userMapper;
@Test
void testAdd(){
UserDto dto = new UserDto();
dto.setUsername("zzz");
dto.setPassword("ddd");
service.add(dto);
Mockito.verify(userMapper, Mockito.times(1))
.add(ArgumentMatchers.argThat(arg->
arg.getUsername().equals("zzz") &&
arg.getPassword().equals("ddd")
));
}
}
断言
断言使用了assertj框架,支持流式调用验证
常用断言
@Test
void testString(){
String name = "张三";
assertThat(name)
.isEqualTo("张三")
.isNotNull()
.isNotEmpty();
String nullName = null;
assertThat(nullName).isNull();
}
@Test
void testNumber(){
Integer num = 1;
assertThat(num)
.isEqualTo(1)
.isNotZero()
.isGreaterThan(0)
.isLessThan(2);
}
异常断言
assertThatThrownBy(() -> this.fieldInfoManager.findlFormFieldInfo(""))
.isInstanceOf(FormException.class)
.hasMessageContaining("form code is null");
assertThatExceptionOfType(Exception.class)
.isThrownBy(()->this.fileService.move(a,c))
.withMessage(null)
集合断言
Collection<FieldInfo> list = this.fieldInfoManager.findlFormFieldInfo("TEST");
assertThat(list)
.hasSize(2)
.extracting(c->c.getCode(),c->c.getTitle())
.contains(Tuple.tuple("name","雇员姓名"));
List<String> result = (List<String>) decoding;
assertThat(result).isNotNull()
.hasSize(2)
.contains("123", Index.atIndex(0))
.contains("456", Index.atIndex(1));
集合类型断言
Assertions.assertThat(list).hasAtLeastOneElementOfType(Object.class);
Assertions.assertThat(list).hasOnlyElementsOfType(Object.class)
Map 断言
FieldInfo name = FieldInfo.builder()
.code("name")
.title("姓名")
.widget(FormFieldWidget.INPUT)
.build();
List<FieldInfo> fieldInfoList = new ArrayList<>();
fieldInfoList.add(name);
Map<String,Object> data = new HashMap<>();
data.put("name","张三");
Map<String,String> result = formDataConvert.convertData(fieldInfoList,data);
assertThat(result)
.isNotNull() // 是否不为空
.isNotEmpty() // 是否size为0
.hasSize(1) // size是否为3
.contains(entry("姓名", "张三")) // 是否包含entry
;
}
对象断言
ptional<File> fileOpt = this.fileService.createNewFile(1L,"新建文件",n3);
File file = fileOpt.orElse(null);
assertThat(file).isNotNull()
.matches(c->c.getDisplayName().equals("新建文件"),"名称")
.matches(c->c.getParentId().equals(n3),"上级id")
.matches(c->c.getContentLength()==0,"文件大小")
.matches(c->c.getAllPath().equals("1.4.5."+file.getId().toString()),"全路径");
Assertions.assertThat(object).hasFieldOrProperty("name")
Assertions.assertThat(object).hasFieldOrPropertyWithValue("name","nameValue")
数据驱动
数据驱动是指使用相同的测试案例,但是每次都使用不同的测试数据,进行一种测试。 数据驱动只要使用的注解是ParameterizedTest、CsvSource、MethodSource 先有一个校验方法,校验字符串条件
- 只能是英文、数据、和下划线
- 首位字符必须是英文字母
public static boolean check(String str){
final String REGEX = "^[a-zA-Z][a-zA-Z0-9_]*$";
return ReUtil.isMatch(REGEX, str);
}
使用CsvSource,模拟csv
@ParameterizedTest
@CsvSource({
"a, true",
"_a1, false",
"a_我1, false",
"User1_ XY_X, false",
})
void testCheck(String name,boolean result){
assertThat(check(name)).isEqualTo(result);
}
使用MethodSource,注意提供的方法必须是static类型
@ParameterizedTest
@MethodSource("methodSource")
@DisplayName("数据驱动测试,MethodSource")
void testCheckWithMethodSource(String name,boolean result){
assertThat(check(name)).isEqualTo(result);
}
public static Stream<Arguments> methodSource() {
return Stream.of(
Arguments.arguments("a",true),
Arguments.arguments("我", false)
);
}
总结
单元测试只测试一小块代码,一般只测试一个方法,单元测试只关注一次测试依赖的内容,尽可能的屏蔽外部因素。 针对一整块的业务测试,从api调用开始,到验证数据是否正常保存到数据库,这样的测试可以放在集成测试阶段测试。
注解
@SpringJUnitConfig @SpringJUnitWebConfig @MockBean @Test @Resource @DisplayName @ParameterizedTest @CsvSource @MethodSource