前言:
为什么需要使用单元测试???
- 保证历史版本代码的正确性,减少重复测试:开发人员实现某个功能或者修补了某个bug,如果有相应的单元测试支持的话,开发人员可以马上通过运行单元测试来验证之前完成的代码是否正确;
- 提升测试效率:对于依赖多,业务流程复杂,可以通过mock去掉依赖,保证自己编写函数的正确性;
- 便于后期重构:保单元测试可以为代码的重构提供保障,只要重构代码之后单元测试全部运行通过,那么在很大程度上表示这次重构没有引入新的BUG,当然这是建立在完整、有效的单元测试覆盖率的基础上;
- 对设计的反馈:单元测试可以反过来指导设计出高内聚、低耦合的模块; 持续集成的一个前提,保证。
-- 下面中介绍Spring Boot项目中的单元测试
目录
Junit+SpringBootTest单元测试
Spring Boot中引入单元测试很简单,依赖如下:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-test'
}
引入spring-boot-starter-test后,有如下几个库:
•JUnit - 一个Java语言的单元测试框架。
•Spring Test&Spring Boot Test - 单元和用于系统的集成测试。
•AssertJ - 一个流畅的断言库。 •Hamcrest - matcher对象库(也称为约束或谓词),可以帮助我们创建匹配器对象。
•Mockito - 是一个针对Java的mocking框架。它与EasyMock和jMock很相似,但是通过在执行后校验什么已经被调用,它消除了对期望行为(expectations)的需要。
•JSONassert - JSON的断言库。 •JsonPath - 用于JSON的XPath。
Service层单元测试
创建测试类:
1、鼠标自动生成
Spring Boot中单元测试类写在在src/test/java目录下,你可以手动创建具体测试类,如果是IDEA,则可以通过IDEA自动创建测试类,如下图:
自动生成测试类如下:
2、Alt+Insert
快捷键,选择Test...
快速生成单元测试类
快速生成Junit单元测试技巧。
Alt+Insert
呼出Generate界面,选择Test...
,呼出右侧界面,其中①处可以选择单元测试所使用的框架或者二类库,②可以指定该类对应的单元测试类名称,③可以指定该单元测试类继承的父类,一般都是统一继承一个父类,父类里面写单元测试的注解、资源初始化、mock登录、资源释放等操作,④处指定生成的单元测试类包全路径,⑤处可以选择是否生成setUp和tearDown方法,一般③处继承父类就不需要勾选了,⑥处指定生成哪些方法的单元测试方法。
编写创建好的测试类,具体代码如下:
@RunWith(SpringRunner.class) // 表明执行程序用spring自己的执行机
@SpringBootTest //表明这是一个springboot测试类,会自动加载springboot主启动程序
public class GsCodeServiceImplTest {
@Autowired
GsCodeServiceImpl codeService;
@Test
public void add() {
GsCode gsCode = codeService.add("pages/visitresults/main?venueId=8ab3b87674d2655e0174d274572d0009",
1, "8ab3b87674d2655e0174d274572d0009");
Assert.assertNotNull(gsCode);
}
@Test
public void update() {
Optional<GsCode> optionalGsCode = codeService.findById("402882817511509201751150eaf00000");
GsCode code = optionalGsCode.get();
code.setCodeName("注册码更新");
GsCode updateCode = codeService.update(code,code.getId());
Assert.assertEquals(code.getCodeName(), updateCode.getCodeName());
}
@Test
public void findById() {
Optional<GsCode> optionalGsCode = codeService.findById("402882817511509201751150eaf00000");
Assert.assertNotNull(optionalGsCode.get());
}
@Test
public void list() {
List<Map<String, Object>> codeList = codeService.list(2);
Assert.assertNotNull(codeList.get(0));
}
@Test
public void pageList() {
Page<Map<String, Object>> pageList = codeService.pageList(1, PageRequest.of(0, 10));
Long items = pageList.getTotalElements();
assertThat(items, allOf(greaterThan(0L), lessThan(11L) ) );
}
@Test
public void delete() {
codeService.delete("402882817511509201751150eaf00000");
Optional<GsCode> optionalGsCode = codeService.findById("402882817511509201751150eaf00000");
Assert.assertNull(optionalGsCode.get());
}
@Test
public void findCodeListByIdList() {
List<String> codeList = new ArrayList();
codeList.add("8ab3b87674d2e0b70174d364a7610001");
List<Map<String, Object>> resultList = codeService.findCodeListByIdList(codeList);
Assert.assertSame(1, resultList.size());
}
}
上面就是最简单的单元测试写法,顶部只要@RunWith(SpringRunner.class))和 SpringBootTest即可,想要执行的时候,鼠标放在对应的方法,右键选择run该方法即可。 测试用例中我使用了assertThat断言,下文中会介绍,也推荐大家使用该断言。
Controller单元测试
上面只是针对Service层做测试,但是有时候需要对Controller层(API)做测试,这时候就得用到MockMvc了,可以不必启动工程就能测试这些接口。
MockMvc实现了对Http请求的模拟,能够直接使用网络的形式,转换到Controller的调用,这样可以使得测试速度快、不依赖网络环境,而且提供了一套验证的工具,这样可以使得请求的验证统一而且很方便。
Controller类:
package com.sncy.fuse.controller;
/**
* @author HJW
* @Description 二维码Controller
*/
@Slf4j
@RestController
@Api(tags = "二维码 接口")
@RequestMapping("gsCode")
public class GsCodeController {
@Autowired
IGsCodeService gsCodeService;
@GetMapping("/list")
@ApiOperation(value = "查询二维码信息列表,无分页", notes = "说明信息")
@ApiImplicitParam(name = "cType", dataType = "int", paramType = "query", value = "二维码类型", required = true)
public Result<List<Map<String, Object>>> list(@RequestParam int cType) {
try {
List<Map<String, Object>> codeInfoList = gsCodeService.list(cType);
log.info("查询二维码信息列表,无分页,codeInfoList:" + codeInfoList);
return new Result(ResultCode.CODE_OK.getValue(), ResultCode.CODE_OK.getDesc(), codeInfoList);
} catch (Exception e) {
log.error("查询二维码信息列表,无分页 异常!", e);
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
}
@GetMapping("/pageList")
@ApiOperation(value = "查询二维码信息列表,有分页", notes = "说明信息")
@ApiImplicitParams({
@ApiImplicitParam(name = "cType", dataType = "int", paramType = "query", value = "二维码类型", required = true),
@ApiImplicitParam(name = "pageNumber", dataType = "int", paramType = "query", value = "当前页数,从0开始", required = true),
@ApiImplicitParam(name = "pageSize", dataType = "int", paramType = "query", value = "每页条数", required = true)
})
public Result<Page<Map<String, Object>>> leaderPageList(int cType, Integer pageNumber, Integer pageSize) {
try {
Pageable pageable = PageRequest.of(pageNumber, pageSize);
Page<Map<String, Object>> pageList = gsCodeService.pageList(cType, pageable);
log.info("查询二维码信息列表,有分页,pageList:" + pageList);
return new Result(ResultCode.CODE_OK.getValue(), ResultCode.CODE_OK.getDesc(), pageList);
} catch (Exception e) {
log.error("查询二维码信息列表,有分页 异常!", e);
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
}
@GetMapping("/downloadCode/{id}")
@ApiOperation(value = "下载二维码", notes = "说明信息")
@ApiImplicitParam(name = "id", dataType = "string", paramType = "query", value = "二维码id", required = true)
public Result downloadCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String id) {
log.error("下载二维码! {}", id);
if (StringUtils.isBlank(id)) {
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
try {
Optional<GsCode> gsCode = gsCodeService.findById(id);
if (!gsCode.isPresent()) {
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "没有id对应的二维码", null);
}
String filename = gsCode.get().getCodeName();
if (StringUtils.isEmpty(filename)) {
filename = "code";
}
//当文件名不是英文名的时候,最好使用url解码器去编码一下,
filename=URLEncoder.encode(filename,"UTF-8");
//将响应的类型设置为图片
String content = gsCode.get().getAddress();
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "attachment;filename=" + filename);
// 通过IO流来传送数据
URL url= new URL(content);
URLConnection uc = url.openConnection();
InputStream input = uc.getInputStream();
OutputStream output = response.getOutputStream();
byte[]buff=new byte[1024*10];//可以自己 指定缓冲区的大小
int len=0;
while((len=input.read(buff))>-1)
{
output.write(buff,0,len);
}
//关闭输入输出流
input.close();
output.close();
} catch (Exception e) {
log.error("下载二维码 异常!", e);
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
return new Result(ResultCode.CODE_OK.getValue(), ResultCode.CODE_OK.getDesc(), null);
}
/**
* 批量下载二维码
*
* @param request 请求
* @param response 相应
* @param codeIdList 二维码id列表
* @return Result
*/
@GetMapping("/batchDownloadCode")
@ApiOperation(value = "下载二维码", notes = "说明信息")
@ApiImplicitParam(name = "codeIdList", dataType = "List", paramType = "query", value = "二维码id列表", required = true)
public Result batchDownloadCode(HttpServletRequest request,
HttpServletResponse response, String[] codeIdList) {
if (CollectionUtils.isEmpty(Arrays.asList(codeIdList))) {
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "参数校验失败!", null);
}
try {
String zipName = URLEncoder.encode( "qr_code.zip", "UTF-8");
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment;filename=\"" + zipName + "\"");
List<Map<String, Object>> codeList = gsCodeService.findCodeListByIdList(Arrays.asList(codeIdList));
ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream());
for(Map<String, Object> codeMap : codeList) {
// 二维码中包含的信息
String codeName = String.valueOf(codeMap.get("codeName"));
if ("null".equals(codeName)) {
codeName = "code" + new Random().nextInt();
} else {
codeName = codeName + new Random().nextInt();
}
String content = String.valueOf(codeMap.get("address"));
ZipEntry entry = new ZipEntry(codeName);
zipOutputStream.putNextEntry(entry);
URL url= new URL(content);
URLConnection uc = url.openConnection();
InputStream input = uc.getInputStream();
byte[] buffer = new byte[1024];
int length = 0;
while ((length = input.read(buffer)) != -1) {
zipOutputStream.write(buffer, 0, length);
}
input.close();
}
zipOutputStream.flush();
zipOutputStream.close();
} catch (Exception e) {
log.error("下载二维码 异常!", e);
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
return null;
}
@Transactional
@PostMapping("/addLoginCode")
@ApiOperation(value = "新增登录码", notes = "说明信息")
public Result addLoginCode(@RequestParam String path) {
log.info("新增登录码,path:" + path);
try {
String url = uploadFileToQiNiu(path);
gsCodeService.addLoginCode(url, 4);
return new Result(ResultCode.CODE_OK.getValue(), ResultCode.CODE_OK.getDesc(), null);
} catch (Exception e) {
log.error("新增登录码 异常!", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return new Result(ResultCode.CODE_Error_BUSINESS.getValue(), "操作失败,请稍后重试!", null);
}
}
private String uploadFileToQiNiu(String filePath) {
String url = "";
try {
File file = new File(filePath + ".png");
FileInputStream fileInputStream = new FileInputStream(file);
MultipartFile multipartFile = new MockMultipartFile("copy"+file.getName(),file.getName(), "application/octet-stream",fileInputStream);
byte[] bytes = multipartFile.getBytes();
//使用base64方式上传到七牛云
String fileName = multipartFile.getOriginalFilename().split("\\.")[0];
log.info("fileName" + fileName);
url = QiniuCloudUtil.put64image(bytes, fileName);
log.info("url" + url);
} catch (Exception e) {
log.error("传到七牛云 异常!", e);
}
return url;
}
}
这里我们也自动创建一个Controller的测试类,具体代码如下:
ackage com.sncy.fuse.controller;
import com.sncy.fuse.entity.GsUser;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@RunWith(SpringRunner.class)
@SpringBootTest
public class GsCodeControllerTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mvc;
private MockHttpSession session;
@Before
public void setupMockMvc(){
mvc = MockMvcBuilders.webAppContextSetup(wac).build(); //初始化MockMvc对象
session = new MockHttpSession();
}
/**
* 释放资源
*
* @throws Exception
*/
@After
public void tearDown() throws Exception {
}
@Test
public void leaderPageList() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/gsCode/pageList?pageNumber=0&pageSize=10&cType=1")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8)
.session(session))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
@Test
public void downloadCode() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/gsCode/downloadCode/8ab3b87674d36cc40174d37858b10004")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8)
.session(session))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
@Test
public void batchDownloadCode() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/gsCode/batchDownloadCode/?codeIdList=8ab3b87674d36cc40174d37858b10004&codeIdList=8ab3b87674d388b20174d3ad235f0013")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8)
.session(session))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
@Test
public void addLoginCode() throws Exception {
mvc.perform(MockMvcRequestBuilders.post("/gsCode/addLoginCode/?path=C:\\Users\\VCEM\\IdeaProjects\\self\\8ab3b87674a03d5a0174a041c3b656565")
.accept(MediaType.APPLICATION_JSON_UTF8)
.session(session)
)
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
}
相关api解释:
- mockMvc.perform 执行一个请求
- MockMvcRequestBuilders.get("XXX")构造一个请求,Post请求就用.post方法
- contentType(MediaType.APPLICATION_JSON_UTF8)代表发送端发送的数据格式是 application/json;charset=UTF-8
- accept(MediaType.APPLICATION_JSON_UTF8)代表客户端希望接受的数据类型为application/json;charset=UTF-8
- session(session)注入一个session,这样拦截器才可以通过
- ResultActions.param() 添加请求传值
- ResultActions.accept() 设置返回类型
- ResultActions.andExpect 添加执行完成后的断言
- ResultActions.andExpect(MockMvcResultMatchers.status().isOk())方法看请求的状态响应码是否为200如果不是则抛异常,测试不通过
- ResultActions.andDo 添加一个结果处理器,表示要对结果做点什么事情。
- ResultActions.andReturn 表示执行完成后,返回响应的结果。
- MockMvcResultHandlers.print()输出整个响应结果信息
新断言assertThat使用
JUnit 4.4 结合 Hamcrest 提供了一个全新的断言语法——assertThat。可以只使用 assertThat 一个断言语句,结合 Hamcrest 提供的匹配符,就可以表达全部的测试思想。
assertThat 的基本语法如下:
assertThat 基本语法
assertThat( [value], [matcher statement] );
- value 是接下来想要测试的变量值;
- matcher statement 是使用 Hamcrest 匹配符来表达的对前面变量所期望的值的声明,如果 value 值与 matcher statement 所表达的期望值相符,则测试成功,否则测试失败。
assertThat 的优点
优点 1:以前 JUnit 提供了很多的 assertion 语句,如:assertEquals,assertNotSame,assertFalse,assertTrue,assertNotNull,assertNull 等,现在有了 JUnit 4.4,一条 assertThat 即可以替代所有的 assertion 语句,这样可以在所有的单元测试中只使用一个断言方法,使得编写测试用例变得简单,代码风格变得统一,测试代码也更容易维护。
优点 2:assertThat 使用了 Hamcrest 的 Matcher 匹配符,用户可以使用匹配符规定的匹配准则精确的指定一些想设定满足的条件,具有很强的易读性,而且使用起来更加灵活。如下表所示:
使用匹配符 Matcher 和不使用之间的比较
// 想判断某个字符串 s 是否含有子字符串 "developer" 或 "Works" 中间的一个 // JUnit 4.4 以前的版本:assertTrue(s.indexOf("developer")>-1||s.indexOf("Works")>-1 ); // JUnit 4.4: assertThat(s, anyOf(containsString("developer"), containsString("Works"))); // 匹配符 anyOf 表示任何一个条件满足则成立,类似于逻辑或 "||", 匹配符 containsString 表示是否含有参数子 // 字符串,文章接下来会对匹配符进行具体介绍 |
优点 3:assertThat 不再像 assertEquals 那样,使用比较难懂的“谓宾主”语法模式(如:assertEquals(3, x);),相反,assertThat 使用了类似于“主谓宾”的易读语法模式(如:assertThat(x,is(3));),使得代码更加直观、易读。
优点 4:可以将这些 Matcher 匹配符联合起来灵活使用,达到更多目的。如下表所示:
Matcher 匹配符联合使用:
// 联合匹配符not和equalTo表示“不等于” |
优点 5:错误信息更加易懂、可读且具有描述性(descriptive)
JUnit 4.4 以前的版本默认出错后不会抛出额外提示信息,如:
assertTrue( s.indexOf("developer") > -1 || s.indexOf("Works") > -1 ); |
如果该断言出错,只会抛出无用的错误信息,如:junit.framework.AssertionFailedError:null。
如果想在出错时想打印出一些有用的提示信息,必须得我们另外手动写,如:
assertTrue( "Expected a string containing 'developer' or 'Works'", |
非常的不方便,而且需要额外代码。
JUnit 4.4 会默认自动提供一些可读的描述信息,如清下表所示:
String s = "hello world!"; |
如何使用 assertThat
JUnit 4.4 自带了一些 Hamcrest 的匹配符 Matcher,但是只有有限的几个,在类 org.hamcrest.CoreMatchers 中定义,要想使用他们,必须导入包 org.hamcrest.CoreMatchers.*。
下表列举了大部分 assertThat 的使用例子:
字符相关匹配符 |
单元测试回滚
单元个测试的时候如果不想造成垃圾数据,可以开启事物功能,记在方法或者类头部添加@Transactional注解即可,如下:
Test
@Transactional
public void update() {
Optional<GsCode> optionalGsCode = codeService.findById("402882817511509201751150eaf00000");
GsCode code = optionalGsCode.get();
code.setCodeName("注册码更新");
GsCode updateCode = codeService.update(code,code.getId());
Assert.assertEquals(code.getCodeName(), updateCode.getCodeName());
}
这样测试完数据就会回滚了,不会造成垃圾数据。如果你想关闭回滚,只要加上@Rollback(false)注解即可。@Rollback表示事务执行完回滚,支持传入一个参数value,默认true即回滚,false不回滚。
如果你使用的数据库是Mysql,有时候会发现加了注解@Transactional也不会回滚,那么你就要查看一下你的默认引擎是不是InnoDB,如果不是就要改成InnoDB。
修改默认引擎的步骤
查看MySQL当前默认的存储引擎:
你要看 gs_code表用了什么引擎(在显示结果里参数engine后面的就表示该表当前用的存储引擎):
show create table gs_code;
CREATE TABLE `gs_code` ( `id` varchar(35) NOT NULL DEFAULT '' COMMENT '主键', `cType` int(1) DEFAULT '0' COMMENT '类型(0注册码 1参观码 2车辆码 3酒店码)', `codeName` varchar(35) DEFAULT NULL COMMENT '二维码名称', `mainId` varchar(35) DEFAULT NULL COMMENT '对应主体id', `address` varchar(300) DEFAULT NULL COMMENT '二维码地址', `createTime` varchar(20) DEFAULT NULL COMMENT '创建时间', `updateTime` varchar(20) DEFAULT NULL COMMENT '修改时间', `isDelete` int(1) DEFAULT '0' COMMENT '是否删除(0未删除,1删除)', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二维码表' |
将gs_code表修为InnoDB存储引擎(也可以此命令将InnoDB换为MyISAM):
ALTER TABLE gs_code ENGINE=INNODB; |
如果要更改整个数据库表的存储引擎,一般要一个表一个表的修改,比较繁琐,可以采用先把数据库导出,得到SQL,把MyISAM全部替换为INNODB,再导入数据库的方式。
转换完毕后重启mysql
service mysqld restart |