前言
编写测试用例是保证代码质量和功能完整性的关键步骤之一。在Springboot中,可以使用JUnit或Mockito等单元测试框架来编写测试用例。首先,需要明确测试的范围和目的,然后选择合适的测试框架并定义测试场景。重点是编写可重复、可维护的测试用例,以确保代码的正确性和可靠性。注意测试数据的准确性和完整性,以及遵循良好的测试实践和规则,例如避免测试耦合和重复等。编写测试用例需要一定的技巧和经验,但是通过不断实践和提高,可以提高测试效率和代码质量。
Springboot版本区别
- 教程使用的Springboot版本:2.0.6.RELEASE
Springboot 2.0.6.RELEASE使用的是Junit4作为单元测试库,在Springboot 2.2.0版本之后,采用的是Junit5
区别:
- Jupiter vs Vintage:JUnit 5有两个主要组件:Jupiter和Vintage。Jupiter是新的编程和扩展模型,而Vintage提供前面版本JUnit的向后兼容性。
- 注解:JUnit 5引入了几个新的注解,例如@DisplayName、@BeforeEach、@AfterEach、@BeforeAll和@AfterAll。这些注解使测试更易读,提供更好的支持设置和清理方法。
- 断言:JUnit 5提供了更全面的断言集,包括assertAll、assertTimeout和assertThrows。这些断言使编写复杂测试更容易,提高了测试覆盖率。
- 参数化测试:JUnit 5对参数化测试的支持有了显著提升。新的@ParameterizedTest注解简化了编写参数化测试的过程,并提供更好的报告和错误消息。
- 扩展模型:JUnit 5的扩展模型比其前身更强大和灵活。扩展可以用于添加测试的功能,甚至整个测试套件。这使编写更模块化和可重用的测试更容易。
- 动态测试:JUnit 5提供了动态测试的支持,这些测试是基于方法调用结果在运行时生成的。这使测试更灵活和适应性更强。
- 并行执行:JUnit 5提供更好的并行测试执行支持。测试可以在方法、类或套件级别并行运行,这使测试执行更快,测试覆盖率更高。
过程
创建测试环境
-
在
pom.xml
中,新增测试依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
导入的依赖如图:
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("测试启动");
}
}
说明:
- 默认情况下,不会启动server
@SpringBootTest
中classes参数指定的是SpringBoot项目的启动类@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");
}
}
说明:
- 通过
@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 是一个外部服务,那就会很难测,因为你的返回结果会直接的受外部服务影响,导致你的单元测试可能今天会过、但明天就过不了了。
而当我们引入 mock 测试时,就可以创建一个假的对象,替换掉真实的 bean B 和 C,这样在调用B、C的方法时,实际上就会去调用这个假的 mock 对象的方法,而我们就可以自己设定这个 mock 对象的参数和期望结果,让我们可以专注在测试当前的类 A,而不会受到其他的外部服务影响,这样测试效率就能提高很多。
比如当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