SpringBoot+Junit5+Assertj+Mockito的单元测试

2,548 阅读6分钟

简介

介绍基于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方法。 如果结合SpringJunitConfigDemoCService进行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");
    }

}

image.png

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");
        }

    }

image.png

完整代码

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 先有一个校验方法,校验字符串条件

  1. 只能是英文、数据、和下划线
  2. 首位字符必须是英文字母
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