⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 blog.csdn.net/new_com/article/details/116098959 「iloveoverfly」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

01、为什么要写单元测试

一聊起测试用例,很多人第一反应就是,我们公司的测试会写测试用例的,我自己也会使用postman或者swagger之类的进行代码自测。那我们研发到底要不要写单元测试用例呢?参考阿里巴巴开发手册,第8条规则(单元测试的基本目标:语句覆盖率达到 70%;核心模块的语句覆盖率和分支覆盖率都要达到 100%),大厂的要求就是必须喽。我个人感觉,写单元测试用例也是很有必要的,好处很多,例如:

  1. 保证代码质量!!!无论初级,中级,高级攻城狮开发工程的代码,且不说效率如何,功能是必要要保证是正确的;交付测试以后,bug锐减,联调飞快。
  2. 代码逻辑“文档化”!!!新人接手维护模块代码时,通过单元测试用例,以debug的方式就能熟悉业务代码。比起,看代码,研究表结构梳理代码结构,效率提升飞快。
  3. 易维护!!!新人接手维护代码模块时,提交自己的代码时,远行之前的单元测试达到回归测试,保证了新改动不会影响老业务。
  4. 快速定位bug!!!在联调期间,测试提出bug后,基于uat环境,编写出错的api测试用例。根据,测试提供的参数和token就可以以debug的方式跟踪问题的所在,如果是在微服务架构中,运行单元测试用例,不会注册本地服务到uat环境,还能过正常请求注册中心的服务。

02、到底如何写单元测试

Java开发springboot项目都是基于junit测试框架,比较MockitoJUnitRunner与SpringRunner与使用,MockitoJUnitRunner基于mockito,模拟业务条件,验证代码逻辑。SpringRunner是MockitoJUnitRunner子类,集成了Spring容器,可以在测试的根据配置加载Spring bean对象。在Springboot开发中,结合@SpringBootTest注解,加载项目配置,进行单元测试。

基于MockitoJUnitRunner的方法测试

以springboot项目为例,一般,对单个的方法都是进行mock测试,在测试方法使用MockitoJUnitRunner,根据不同条件覆盖测试。使用@InjectMocks注解,可以让模拟的方法正常发起请求;@Mock注解可以模拟期望的条件。以删除菜单服务为例,源码如下:

@Service
public class MenuManagerImpl implements IMenuManager {

/**
* 删除菜单业务逻辑
**/
@Override
@OptimisticRetry
@Transactional(rollbackFor = Exception.class)
public boolean delete(Long id) {

if (Objects.isNull(id)) {
return false;
}
Menu existingMenu = this.menuService.getById(id);
if (Objects.isNull(existingMenu)) {
return false;
}
if (!this.menuService.removeById(id)) {
throw new OptimisticLockingFailureException("删除菜单失败!");
}
return true;
}
}


/**
* 删除菜单方法级单元测试用例
**/
@RunWith(MockitoJUnitRunner.class)
public class MenuManagerImplTest {

@InjectMocks
private MenuManagerImpl menuManager;

@Mock
private IMenuService menuService;

@Test
public void delete() {

Long id = null;
boolean flag;
// id为空
flag = menuManager.delete(id);
Assert.assertFalse(flag);

// 菜单返回为空
id = 1l;
Mockito.when(this.menuService.getById(ArgumentMatchers.anyLong())).thenReturn(null);
flag = menuManager.delete(id);
Assert.assertFalse(flag);

// 修改成功
Menu mockMenu = new Menu();
Mockito.when(this.menuService.getById(ArgumentMatchers.anyLong())).thenReturn(mockMenu);
Mockito.when(this.menuService.removeById(ArgumentMatchers.anyLong())).thenReturn(true);
flag = menuManager.delete(id);
Assert.assertTrue(flag);
}
}

基于SpringRunner的Spring容器测试

在api开发过程中,会对单个api的调用链路进行验证,对第三方服务进行mock模拟,本服务的业务逻辑进行测试。一般,会使用@SpringBootTest加载测试环境的Spring容器配置,使用MockMvc以http请求的方式进行测试。以修改新增菜单测试用例为例,如下:

/**
* 成功新增菜单api
*/
@Api(tags = "管理员菜单api")
@RestController
public class AdminMenuController {

@Autowired
private IMenuManager menuManager;


@PreAuthorize("hasAnyAuthority('menu:add','admin')")
@ApiOperation(value = "新增菜单")
@PostMapping("/admin/menu/add")
@VerifyLoginUser(type = IS_ADMIN, errorMsg = INVALID_ADMIN_TYPE)
public Response<MenuVo> save(@Validated @RequestBody SaveMenuDto saveMenuDto) {
return Response.success(menuManager.save(saveMenuDto));
}
}



/**
* 成功新增菜单单元测试用例
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MallSystemApplication.class)
@Slf4j
@AutoConfigureMockMvc
public class AdminMenuControllerTest extends BaseTest {

/**
* 成功新增菜单
*/
@Test
public void success2save() throws Exception {

SaveMenuDto saveMenuDto = new SaveMenuDto();
saveMenuDto.setName("重置密码");
saveMenuDto.setParentId(1355339254819966978l);
saveMenuDto.setOrderNum(4);
saveMenuDto.setType(MenuType.button.getValue());
saveMenuDto.setVisible(MenuVisible.show.getValue());
saveMenuDto.setUrl("https:baidu.com");
saveMenuDto.setMethod(MenuMethod.put.getValue());
saveMenuDto.setPerms("user:reset-pwd");
// 发起http请求
MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders
.post("/admin/menu/add")
.contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)
.content(JSON.toJSONString(saveMenuDto))
.accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
.header(GlobalConstant.AUTHORIZATION_HEADER, GlobalConstant.ADMIN_TOKEN))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print())
.andReturn();
Response<MenuVo> response = JSON.parseObject(mvcResult.getResponse().getContentAsString(), menuVoTypeReference);
// 断言结果
Assert.assertNotNull(response);
MenuVo menuVo;
Assert.assertNotNull(menuVo = response.getData());
Assert.assertEquals(menuVo.getName(), saveMenuDto.getName());
Assert.assertEquals(menuVo.getOrderNum(), saveMenuDto.getOrderNum());
Assert.assertEquals(menuVo.getType(), saveMenuDto.getType());
Assert.assertEquals(menuVo.getVisible(), saveMenuDto.getVisible());
Assert.assertEquals(menuVo.getStatus(), MenuStatus.normal.getValue());
Assert.assertEquals(menuVo.getUrl(), saveMenuDto.getUrl());
Assert.assertEquals(menuVo.getPerms(), saveMenuDto.getPerms());
Assert.assertEquals(menuVo.getMethod(), saveMenuDto.getMethod());
}
}

具体编写单元测试用例规则参考测试用例的编写。简单说,一般api的单元测试用例,编写两类,如下:

  1. 业务参数的校验,和义务异常的校验。例如,名称是否为空,电话号码是否正确,用户未登陆则抛出未登陆异常。
  2. 各类业务场景的真实测试用例,例如,编写成功添加顶级菜单的测试用例,已经编写成功添加子级菜单的测试用例。

注意事项

  • 配置覆盖

此外,如上基于mockmvc的编写的测试用例,由于加载了Spring的配置,会对项目发起真实的调用。如果,环境的配置为线上配置,容易出现安全问题;一般,处于安全考虑,很多公司会对真实环境的修改操作做事务回滚操作,甚至根本就不会进行真实环境的调用,使用模拟环境替换,例如数据库的操作可以使用h2内存数据库进行替换。

这时,可以在src/test/resources目录下,添加与src/main/resources目录下,相同的文件进行配置覆盖。src/test/main目录下的代码,会首先加载src/test/resources目录下的配置,如果没有则在加载src/main/resources目录的配置。常用场景如下:

  1. 在单元测试环境使用使用内存数据库。
  2. ginkens代码集成运行测试用例时,不希望在集成环境中输出日志文件信息,并且以debug级别输出日志。

以日志文件配置覆盖为例,在src/main/resources目录下配置日志有文件和控制台输出,如图:

main/resource目录下的logback-spring.xml,内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">

<contextName>mall-system</contextName>

<!-- 控制台日志输出配置 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%level][%thread] [%contextName] [%logger{80}:%L] %msg%n
</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
</appender>

<!-- 日志文件输出配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>log/info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>log/info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>50</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%level] [%contextName] [%logger{80}:%L] %msg%n</pattern>
</encoder>
</appender>

<!-- 设置INFO 级别输出日志 -->
<root level="INFO">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>

src/test//resource目录下的新增logback-spring.xml,去掉日志文件输出的配置,设置日志输出级别为DEBUG;如果运行测试用例,则加载该配置不会进行日志文件的输出,并且打印DEBUG级别日志。如图:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">

<contextName>mall-system</contextName>

<!-- 控制台日志输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%level][%thread] [%contextName] [%logger{80}:%L] %msg%n
</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
</appender>

<!-- DEBUG级别日志输出 -->
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

  • 指定环境

一般开发过程中,我们研发只会操作开发环境,也是为了避免数据安全问题,可以在单元测试用例中指定运行的环境配置。在测试类加上@ActiveProfiles("dev"),指定获取dev环境的配置。示例,

/**
* 获取dev环境配置
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MallSystemApplication.class)
@Slf4j
@AutoConfigureMockMvc
@ActiveProfiles("dev")
public class AdminMenuControllerTest extends BaseTest {
}

在联调测试中,对于出错的api,可以编写对应的单元测试用例,使用@ActiveProfiles("uat")指定到测试环境,就可以根据测试提供的参数快速定位问题。示例:

/**
* 新增菜单api联调
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MallSystemApplication.class)
@Slf4j
@AutoConfigureMockMvc
@ActiveProfiles("uat")
public class AdminMenuControllerTest extends BaseTest {

/**
* 成功新增菜单
*/
@Test
public void success2save() throws Exception {

String token="Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjhjMjhlZWEzLTA5MWEtNDA1OS1iMzliLTRjOGMyNGY4ZjEzMiJ9.xK9srWjeGaq4NXt4BzG2MQ_yN9IaYtPVjKj5MoSS4bX9Ytf1XJNe_NSupR0IItkB48G6mXVZwj5CIwWIYzvsEA";
String paramJson="{
"name":"mayuan",
"parentId":"1",
"orderNum":"1",
"type":"1",
"visible":true,
"url":"https:baidu.com",
"method":2,
"perms":"user:reset-pwd"
}";
// 发起http请求
MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders
.post("/admin/menu/add")
.contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)
.content(paramJson)
.accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
.header(GlobalConstant.AUTHORIZATION_HEADER, token))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print())
.andReturn();
}
}

文章目录
  1. 1. 01、为什么要写单元测试
  2. 2. 02、到底如何写单元测试