前言

编写测试用例是保证代码质量和功能完整性的关键步骤之一。在Springboot中,可以使用JUnit或Mockito等单元测试框架来编写测试用例。首先,需要明确测试的范围和目的,然后选择合适的测试框架并定义测试场景。重点是编写可重复、可维护的测试用例,以确保代码的正确性和可靠性。注意测试数据的准确性和完整性,以及遵循良好的测试实践和规则,例如避免测试耦合和重复等。编写测试用例需要一定的技巧和经验,但是通过不断实践和提高,可以提高测试效率和代码质量。

Springboot版本区别

  • 教程使用的Springboot版本:2.0.6.RELEASE

Springboot 2.0.6.RELEASE使用的是Junit4作为单元测试库,在Springboot 2.2.0版本之后,采用的是Junit5

区别:

  1. Jupiter vs Vintage:JUnit 5有两个主要组件:Jupiter和Vintage。Jupiter是新的编程和扩展模型,而Vintage提供前面版本JUnit的向后兼容性。
  2. 注解:JUnit 5引入了几个新的注解,例如@DisplayName、@BeforeEach、@AfterEach、@BeforeAll和@AfterAll。这些注解使测试更易读,提供更好的支持设置和清理方法。
  3. 断言:JUnit 5提供了更全面的断言集,包括assertAll、assertTimeout和assertThrows。这些断言使编写复杂测试更容易,提高了测试覆盖率。
  4. 参数化测试:JUnit 5对参数化测试的支持有了显著提升。新的@ParameterizedTest注解简化了编写参数化测试的过程,并提供更好的报告和错误消息。
  5. 扩展模型:JUnit 5的扩展模型比其前身更强大和灵活。扩展可以用于添加测试的功能,甚至整个测试套件。这使编写更模块化和可重用的测试更容易。
  6. 动态测试:JUnit 5提供了动态测试的支持,这些测试是基于方法调用结果在运行时生成的。这使测试更灵活和适应性更强。
  7. 并行执行:JUnit 5提供更好的并行测试执行支持。测试可以在方法、类或套件级别并行运行,这使测试执行更快,测试覆盖率更高。

过程

创建测试环境

  1. pom.xml中,新增测试依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    

    导入的依赖如图:

    image-20220829155822602

    SpringBootTest默认集成了以下功能:

    • junit4:Java单元测试框架
    • spring-test & spring-boot-test:SpringBoot的测试工具和支持
    • hamcrest:Hamcrest断言
    • jsonassert:Json断言
    • json-path:XPath for Json
    • assertj:流式断言
    • mockito:Java Mock框架

创建单元测试类

简单测试

package com.lbx.ms.biz.hrm;

import com.lbx.ms.biz.LbxMsBizHRMApplication;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LbxMsBizHRMApplication.class)
@ActiveProfiles("test")
public class LbxTest {

    @Test
    public void test() {
        System.out.printf("测试启动");
    }

}

说明:

  1. 默认情况下,不会启动server
  2. @SpringBootTest中classes参数指定的是SpringBoot项目的启动类
  3. @ActiveProfiles("env")可以指定项目使用的配置文件

集成测试

package com.lbx.ms.biz.hrm;

import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import com.jayway.jsonpath.JsonPath;
import com.lbx.ms.biz.LbxMsBizHRMApplication;
import com.lbx.ms.biz.hrm.v2.domain.atresource.AtResourcePartnerQuery;
import org.json.JSONException;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LbxMsBizHRMApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ServerTest {

    @LocalServerPort
    private Integer port;

    @Test
    public void test() {
        System.out.printf(port.toString());
        assertThat(port).isGreaterThan(1024);
    }

    @Test
    public void test2() throws JSONException {
        
        // 构造请求
        AtResourcePartnerQuery query = new AtResourcePartnerQuery();
        query.setPage(1);
        query.setSize(10);
        query.setLoginId("00087585");

        // 发起HTTP请求
        HttpResponse httpResponse = HttpRequest.post("localhost:" + port + "/atresource/v1/getlistbypage").body(JSONUtil.toJsonStr(query)).execute();

        // 断言返回状态码是200
        assertThat(httpResponse.getStatus()).isEqualTo(200);
        System.out.printf(httpResponse.body());

        // 定义一个预期的返回结果
        String a = "{\"code\":0,\"msg\":\"操作成功!\",\"detail\":null,\"error\":null,\"data\":null,\"ext\":null}";
        // 断言返回的body跟预期一致
        JSONAssert.assertEquals(a, httpResponse.body(), JSONCompareMode.LENIENT);

        // 获取body中的code值,利用XPath
        Integer code = JsonPath.read(httpResponse.body(), "$.code");
        // 断言返回的body里获得的code是0
        assertThat(code).isZero();

        // 通过Xpath获取body中rows集合的login_id,组成集合
        List<String> list = JsonPath.read(httpResponse.body(), "$.data.rows[*].login_id");
        // 断言列表中,只存在'00087585'字段
        assertThat(list).containsOnly("00087585");
    }
}

说明:

  1. 通过@SpringBootTest注解中的参数webEnvironment可以指定启动server的类型
  • SpringBootTest.WebEnvironment.NONE:启动一个非Web的ApplicationContext,既不提供mock环境,也不提供真实的web服务

  • SpringBootTest.WebEnvironment.RANDOM_PORT:启动一个真实的web服务,监听一个随机端口

  • SpringBootTest.WebEnvironment.DEFINED_PORT:启动一个真实的web服务,监听一个定义好的端口(从配置中读取)

  • SpringBootTest.WebEnvironment.MOCK:mock环境,此时不会真正启动server,也不会监听Web端口

Mock

package com.lbx.ms.biz.hrm;

import com.lbx.framework.common.domain.common.ResponseResult;
import com.lbx.framework.common.util.ResponseResultUtil;
import com.lbx.ms.biz.LbxMsBizHRMApplication;
import com.lbx.ms.biz.hrm.v2.domain.atresource.AtResourceQuery;
import com.lbx.ms.biz.hrm.v2.domain.atresource.model.AtResourceReq;
import com.lbx.ms.biz.hrm.v2.service.impl.AtResourceServiceImpl;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.*;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LbxMsBizHRMApplication.class)
public class MockTest {

    private static AtResourceServiceImpl mockAtResourceService;
    private static List<String> mocksList;

    @Before
    public void beforeMock() {
        // 使用Mock,模拟AtResourceServiceImpl对象
        mockAtResourceService = mock(AtResourceServiceImpl.class);
        // mock创建对接
        mocksList = mock(List.class);

        //做一些测试桩(stubbing),也即是定义行为,getById(3),则返回的是null,2则抛出异常
        when(mockAtResourceService.getById("1")).thenReturn(ResponseResultUtil.success("张三", "success"));
        when(mockAtResourceService.getById("2")).thenThrow(new IllegalStateException());
        when(mockAtResourceService.getById("3")).thenReturn(null);
        when(mockAtResourceService.getAllLoginId(isA(AtResourceQuery.class))).thenReturn("true");

    }

    @Test
    public void testGet() {
        ResponseResult responseResult1 = mockAtResourceService.getById("1");
        ResponseResult responseResult2 = mockAtResourceService.getById("2");
        ResponseResult responseResult3 = mockAtResourceService.getById("3");
        
        // 返回ResponseResultUtil.success("张三", "success")
        System.out.println(responseResult1);
        // 抛出IllegalStateException
        System.out.println(responseResult2);
        // 返回null
        System.out.println(responseResult3);
        // 返回true
        System.out.println(mockAtResourceService.getAllLoginId(new AtResourceQuery()));

        // 验证是否执行过一次getById("1")
        verify(mockAtResourceService, times(1)).getById("1");
        // 验证是否执行过一次getAllLoginId
        verify(mockAtResourceService, times(1)).getAllLoginId(isA(AtResourceQuery.class));

    }
    
    @Test
    public void testAdd() {
        // 构建请求对象
        AtBizOrgAddReq req = new AtBizOrgAddReq();
        req.setAtOrgId(1);
        req.setDesc("desc");
        req.setBizDeptId(1);
        req.setType(1);
        req.setCreationDate(new Date());
        req.setCreationUser("user");

        // 打桩
        atBizOrgAddReqBean(req);
        
        // 断言测试
        assertThat(atBizoOrgService.add(req)).isEqualTo(ResponseResultUtil.success());
    }

    private void atBizOrgAddReqBean(AtBizOrgAddReq req) {
          // 如果在这里新建对象,则Mock环境与请求的结果不一致,上面断言会失败(原理是req实体不一样)
//        req = new AtBizOrgAddReq();
//        req.setAtOrgId(1);
//        req.setDesc("desc");
//        req.setBizDeptId(1);
//        req.setType(1);
//        req.setCreationDate(new Date());
//        req.setCreationUser("user");
        
        // 将atBizoOrgService.add(req)注入给Mock环境,并返回ResponseResultUtil.success()
        BDDMockito.given(atBizoOrgService.add(req)).willReturn(ResponseResultUtil.success());
    }

}

XPath

JsonPath 描述
$ 根节点
@ 当前节点
.or[] 子节点
选择所有符合条件的节点
* 所有节点
[] 迭代器标示,如数组下标
[,] 支持迭代器中做多选
[start🔚step] 数组切片运算符
?() 支持过滤操作
() 支持表达式计算
private static void jsonPathTest() {
    JSONObject json = jsonTest();//调用自定义的jsonTest()方法获得json对象,生成上面的json

    //输出book[0]的author值
    String author = JsonPath.read(json, "$.store.book[0].author");

    //输出全部author的值,使用Iterator迭代
    List<String> authors = JsonPath.read(json, "$.store.book[*].author");

    //输出book[*]中category == 'reference'的book
    List<Object> books = JsonPath.read(json, "$.store.book[?(@.category == 'reference')]");               

    //输出book[*]中price>10的book
    List<Object> books = JsonPath.read(json, "$.store.book[?(@.price>10)]");

    //输出book[*]中含有isbn元素的book
    List<Object> books = JsonPath.read(json, "$.store.book[?(@.isbn)]");

    //输出该json中所有price的值
    List<Double> prices = JsonPath.read(json, "$..price");

    //可以提前编辑一个路径,并多次使用它
    JsonPath path = JsonPath.compile("$.store.book[*]"); 
    List<Object> books = path.read(json); 
}

JsonAssert

String expect = "{\"code\":0,\"msg\":\"操作成功!\",\"detail\":null,\"error\":null,\"data\":null,\"ext\":null}";
// 非严格模式,字段顺序无关紧要
JSONAssert.assertEquals(expect, httpResponse.body(), false);

// 严格模式,强行指定按顺序
JSONAssert.assertEquals(expect, httpResponse.body(), true);

// 也可以使用以下方式,指定比较类型
JSONAssert.assertEquals(a, httpResponse.body(), JSONCompareMode.LENIENT);

JSONCompareMode:

  • STRICT:不可拓展,指定顺序
  • NON_EXTENSIBLE:不可拓展,不指定顺序
  • LENIENT:可拓展,不指定顺序
  • STRICT_ORDER:可拓展,指定顺序

Mockito

Mockito 是一种 Java Mock 框架,主要是用来做 Mock 测试,它可以模拟任何 Spring 管理的 Bean、模拟方法的返回值、模拟抛出异常等等,避免你为了测试一个方法,却要自行构建整个 bean 的依赖链。

像是以下这张图,类 A 需要调用类 B 和类 C,而类 B 和类 C 又需要调用其他类如 D、E、F 等,假设类 D 是一个外部服务,那就会很难测,因为你的返回结果会直接的受外部服务影响,导致你的单元测试可能今天会过、但明天就过不了了。

img

而当我们引入 mock 测试时,就可以创建一个假的对象,替换掉真实的 bean B 和 C,这样在调用B、C的方法时,实际上就会去调用这个假的 mock 对象的方法,而我们就可以自己设定这个 mock 对象的参数和期望结果,让我们可以专注在测试当前的类 A,而不会受到其他的外部服务影响,这样测试效率就能提高很多。

img

比如当userDao方法还没实现时,可以用Mockito模拟userDao.getUserById(3)时返回一个用户id为200的User:

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {
    
    @Autowired
    private UserService userService;
    
    @MockBean
    private UserDao userDao;

    @Test
    public void getUserById() throws Exception {
        // 定义当调用mock userDao的getUserById()方法,并且参数为3时,就返回id为200、name为I'm mock3的user对象
        Mockito.when(userDao.getUserById(3)).thenReturn(new User(200, "Aritisan"));
        
        // 返回的会是名字为I'm mock 3的user对象
        User user = userService.getUserById(1);

        Assert.assertNotNull(user);
        Assert.assertEquals(user.getId(), new Integer(200));
        Assert.assertEquals(user.getName(), "Aritisan");
    }
}

当使用 Mockito 在 Mock 对象时,有一些限制需要遵守:

  • 不能 Mock 静态方法
  • 不能 Mock private 方法
  • 不能 Mock final class

参考文档

  1. JSONAssert –如何对JSON数据进行单元测试
  2. JSONPath
  3. Mockito framework site