基于链路思想的SpringBoot单元测试快速写法
简介:本文更偏向实践而非方法论,所提及的SpringBoot单元测试写法亦并非官方解,仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格,只要能实现单元测试的效果,就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得,帮助像笔者这样的新人快速成长。
作者 | 桃符
来源 | 阿里技术公众号
引言:
本文更偏向实践而非方法论,所提及的SpringBoot单元测试写法亦并非官方解,仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格,只要能实现单元测试的效果,就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得,帮助像笔者这样的新人快速成长。
一 为什么要写单元测试?
测试是Devops上极重要的一环,但大多数开发的眼光都停留在集成测试这一环——只要能联调成功,那么我这次准备上线的特性一定是没问题的。
老实承认,我曾经是这样的可能现在也还是这样。作为非科班出身的笔者,研究生毕业后就立即进入了同在杭州的xx厂,先后参与了内部Devops平台建设和xx云Paas项目开荒,在这两个项目中,开发 > 测试是很正常的场景,甚至部分测试也是原开发友情客串的:由于缺少专业的测试人员,开发往往需要兼顾集成测试甚至是线上测试的活儿。为了提高效率,我将一部分常用的测试用例维护在了内部的自动化测试平台上。即便如此,我仍能清晰地感觉到,测试所能覆盖的场景屈指可数,以至于每次自信地上线大特性后,都会因一些奇怪的问题而定位到大半夜。幸亏后面遇到了一位资深大佬,在code review时,他直接点出我不写单元测试的坏习惯,并用自身惨痛的线上教训反复强调单测的重要性。
当然上述只是我的亲身经历,勉强作为日常闲聊的谈资。如果想要深入理解单元测试的重要性,推荐Google上搜索the importance of unit test关键字,可以感受下不同国家、不同领域的程序员对单元测试的不同理解,想必能有更大的收获。
二 为什么推荐链路思想?
深入接触单元测试,开发难免会遇到以下场景:
- 应该如何设计测试用例?
- 应该如何编写测试用例?
- 测试用例的质量该如何判定?
刚开始学习写单元测试,我也曾参考并尝试过网上五花八门的写法。这些写法可能用到了不同的单测框架,也可能侧重了不同的代码环节(例如特定的某个service方法)。一开始我为自己能够熟练使用多种单测框架而沾沾自喜,但随着工作的推进,我逐渐意识到,单元测试中重要的并不是框架选型,而是如何设计一套优秀的用例。之所以用"一套"而不是"一个",是因为在我们的业务代码中,逻辑往往并非"一帆风顺",有许多if-else会妆点我们的业务代码。显然对于这类业务代码,"一个"测试用例无法完全满足所有可能出现的场景。如果为了偷懒,尝试仅仅用"一个"用例去覆盖主流程,无异于给自己埋了个雷——线上场景可没"一个"用例这么简单!
我开始专注于测试用例的设计,从输入输出开始,重新审视曾经开发过的代码。我发现,如果将某个controller方法作为入口,那这一套业务流程可以当做一条链路,而上下文中所关联的service层、dao层、api层的各方法都可以作为链路上的各环节。通过绘制链路图,将各环节根据是否关联外部系统大致分成黑、白两类,整套业务流程和各环节的潜在分支便会变得清晰,测试用例便从"一个"自然而然地变成了"一套"。此处多提一嘴,链路思想设计用例的基础是结构清晰、圈复杂度可控制的代码风格,如果开发的时候依然尊崇"论文式"、"一刀流",在单个方法内"长篇大论",那链路式将是一个巨大的负担。
编写测试用例其实不是一件费劲的事,对于深耕业务代码的开发而言,编写测试用例便像是做一盘小菜,举手可为。于我而言,如今写测试用例所花费的时间甚至没有设计测试用例的时间长(凸显用例设计的重要性但也有可能是我对测试用例的设计还不够熟练)。在测试框架选型上,我更习惯于Junit+Mockito的组合,原因仅仅是熟悉与简单,且参考文档比比皆是。如果各位已经有自己习惯的框架和写法,也不必照搬本文所提及的东西,毕竟单测是为了better code,而不是自找麻烦。
但无论测试用例如何设计或是如何编写,我始终认为,在不考虑测试代码的风格和规范的前提下,衡量测试用例质量的核心指标是分支覆盖率。这也是我推荐链路思想的一大原因——从入口出发,遍历链路上各个环节的各个分支,遇到阻碍就Mock;相比于分别单测各个独立方法,单测链路所需要的入参和出参更加清晰,更是大大节省了编写测试代码所需的时间成本!计算分支覆盖率的工具有很多,例如本地的JaCoCo或是各类云化测试工具。试想,每当看到单测完美地覆盖了自己所提交的特性代码时,心里是不是放心了许多?

三 如何用链路思想设计/构造单测?
作为程序员,大家更为熟悉的链路概念应该是全链路压测。
全链路压测简单来说,就是基于实际的生产业务场景、系统环境,模拟海量的用户请求和数据对整个业务链进行压力测试,并持续调优的过程,本质上也是性能测试的一种手段。... 通过这种方法,在生产环境上落地常态化稳定压测体系,实现IT系统的长期性能稳定治理。
如果将完整的业务流程视作全链路,那作为业务链上的一环,即某个后端服务,它其实也是一个微链路。这里以自上而下的开发流程为例,对于新增的功能接口,我们会习惯性地由controller开始设计,然后构建service层、dao层、api层,最后再锦上添花地加些aop。如果以链路思想,将复杂的流程拆成各个链路的各个环节,那这样的代码功能清晰,维护起来也相当方便。我非常认同 限制单个方法行数<=50 的代码门禁,对于长篇大论的代码“论文”,想必没有哪位接手的同学脸上能露出笑容的;针对这类代码,我认为clean code的优先级比补充单测用例更高,连逻辑都无法理清,即便硬着头皮写出单测用例,后续的调试和维护工作量也是不可预料的(试想,假如后面有位A同学接手了这块代码,他在“论文”中加了xx行导致ut失败了,他该如何去定位问题)。

简单画个图来强调一下我的观点。这是一张"用户买猪"的功能逻辑图。以链路思想,开发人员将整套流程拆分为相应的链路环节,涵盖了controller、service、dao、api各层;整条链路清晰明了,只要搭配完善的上下文日志,定位线上问题亦是轻而易举。
当然,基于链路思想的开发还远远不够,在补充单测用例时,我们同样也能用链路思想来构造测试用例。测试用例的要求很简单,需要覆盖controller、service等自主编写的代码(多分支场景也需要完全覆盖),对于周边关联的系统可以采用Mock进行屏蔽,对于Dao层的SQL可以视需求决定是否Mock。秉承这个思路,我们可以对“用户买猪”图进行改造,将允许Mock的环节涂灰,从而变成我们在编写单元测试用例时所需要的“虚拟用户买猪”图。

四 快速写法实践案例
1 快速写法的核心步骤有哪些?
快速写法的入口是controller层方法,这样对于controller层存在的少量逻辑代码也能做到覆盖。
设计测试用例的输入与预期输出
设计测试用例的目的不仅仅是跑通主流程,而是要跑通全部可能的流程,即所谓的分支全覆盖,因此设计用例的输入与输出尤为重要。即便是新增分支的增量修改(例如加了一行if-else),也需要补充相应的输入与预期输出。非常不建议根据单测运行结果修改预期结果,这说明原先的代码设计有问题。
确定链路上的全部Mock点
Mock点的判断依据是链路上该环节是否依赖第三方服务。强烈建议在设计前画出大概的功能流程图(如”用户买猪“图),这可以大大提高确定Mock点的速度和准确性。
收集Mock点的模拟返回数据
确定Mock点后,我们就需要构造相应的模拟返回数据。Mock数据需要考虑多个因素:
a. 是否与api层对应方法的期望返回值匹配: 不能把从猪厂返回的Mock数据用牛肉替代
b. 是否与模拟输入数据匹配:用户需要1斤猪肉,不能返回5斤猪肉的数据
c. 是否与api层的所有分支匹配:部分api层会对返回值进行响应码(2xx || 3xx || 4xx)校验,这类场景便需要构造不同响应码的Mock数据
2【开发篇】真实用户买猪
该项目基于PandoraBoot构建,手动升级SpringBoot版本至2.5.1,使用Mybatis-plus组件简化Dao层开发过程。下面选取了上文图中所涉及的重要方法进行展示,仅实现了简单的业务流程,系统框架和工程结构可以参考代码仓。
业务对象
PorkStorage.java - 猪肉库存的数据库实体类
/**
* 猪肉库存的数据库实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName(value = "pork_storage", autoResultMap = true)
public class PorkStorage {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long cnt;
}

PorkInst.java - 猪肉实例,由仓库打包后生成
/**
* 猪肉实例,由仓库打包后生成
**/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PorkInst {
/**
* 重量
*/
private Long weight;
/**
* 附件参数,例如包装类型,寄送地址等信息
*/
private Map< String, Object> paramsMap;
}

业务代码
PorkController.java
@RestController
@Slf4j
@RequestMapping("/pork")
public class PorkController {
@Autowired
private PorkService porkService;
@PostMapping("/buy")
public ResponseEntity< PorkInst> buyPork(@RequestParam("weight") Long weight,
@RequestBody Map< String,Object> params) {
if (weight == null) {
throw new BaseBusinessException("invalid input: weight", ExceptionTypeEnum.INVALID_REQUEST_PARAM_ERROR);
}
return ResponseEntity.ok(porkService.getPork(weight, params));
}
}

PorkService.java
public interface PorkService {
/**
* 获取猪肉打包实例
*
* @param weight 重量
* @param params 额外信息
* @return {@link PorkInst} - 指定数量的猪肉实例
* @throws BaseBusinessException 如果猪肉库存不足,返回异常,同时后台告知工厂
*/
PorkInst getPork(Long weight, Map< String, Object> params);
}

PorkStorageDao.java
@Mapper
public interface PorkStorageDao extends BaseMapper< PorkStorage> {
PorkStorage queryStore();
}

PorkStorageDao.xml
< ?xml version="1.0" encoding="UTF-8"?>
< !DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
< mapper namespace="com.alibaba.ut.demo.dao.PorkStorageDao">
< sql id="columns">id, cnt< /sql>
< sql id="table_name">pork_storage< /sql>
< select id="queryStore" resultType="com.alibaba.ut.demo.entity.PorkStorage">
select
< include refid="columns"/>
from
< include refid="table_name"/>
where id = 1
< /select>
< /mapper>

FactoryApi.java
public interface FactoryApi {
void supplyPork(Long weight);
}

FactoryApiImpl.java
@Service
@Slf4j
public class FactoryApiImpl implements FactoryApi {
@Override
public void supplyPork(Long weight) {
log.info("call real factory to supply pork, weight: {}", weight);
}
}

WareHouseApi.java
public interface WareHouseApi {
PorkInst packagePork(Long weight, Map< String, Object> params);
}

WareHouseApiImpl.java
@Service
@Slf4j
public class WareHouseApiImpl implements WareHouseApi {
@Override
public PorkInst packagePork(Long weight, Map< String, Object> params) {
log.info("call real warehouse to package, weight: {}", weight);
return PorkInst.builder().weight(weight).paramsMap(params).build();
}
}

3【单测篇】虚拟用户买猪
单测依赖
对于PandoraBoot工程,可参考下文的Maven配置引入相关依赖。
对于非PandoraBoot工程,仅需引入Junit和Mockito两个包即可。
注本章所提到的单测写法默认Mock Dao层且无需启动容器应用。如果不想Mock Dao层,建议在依赖中引入H2这类内存型数据库,同时支持本地启动容器应用。

写法思路
在阅读下面的内容前,强烈建议先学习Junit和Mockito的基本用法和运行原理,包括但不限于下文写法中可能涉及的注解:
Junit原生流Method注解:@Before 、@Test、@After
Mockito原生Field注解:@Mock、@InjectMocks、@Spy
在已知待单测业务链路的前提下,写法可以简要归纳为以下几步:
- 初步设计单测用例框架。包括setup、teststep、teardown三步,setup负责处理一些全局必要的单测前置逻辑(例如Mock数据插入和环境准备),teststep承载单测用例的主体(要求以Assert类近似的断言语句为结尾),teardown负责处理一些全局必要的收尾逻辑(例如Mock数据删除和环境释放)
- 声明并初始化用例所涉及的所有链路环节。在已知链路流程的前提下,所有环节都可以依据是否为Mock点方法大致分为两类(参考上文中"用户买猪"图的灰、白点)。
- 非Mock点方法:对于链路中非入口的环节(通常将controller作为入口,其他方法即为非入口),需要标注@Spy以声明该对象在单测链路中为监听状态,即需要正常走完流程。此处根据方法内是否引用Mock点方法进一步分成两类。
- 该方法内引用了其他Mock点方法,需要在@Spy的基础上额外标注@InjectMocks,声明该对象在单测链路中需要被注入其他Mock对象。
- 该方法内未引用其他Mock点方法,无需进行其他操作。
- Mock点方法:标注@Mock以声明该对象在单测链路中需要被Mock,可以通过org.mockito.Mockito类内的一系列static方法手动注入Mock值(ep. when(A()).thenReturn(B))。
- 编写单测用例主体。在teststep中从controller层发起方法调用,最终通过Assert断言结果判断用例的成功与否。除了普通的返回值校验场景外,Junit也支持用@Test(expected = xxException.class)来声明该用例期望发生的异常类型。最后还是建议写完单测后能够以注释的形式说明该单测所支持的场景和预期结果的大致说明,方便以后自己和其他接手的同学能够快速了解这个单测用例的相关信息。
这里仍以"用户买猪"的场景为例,依照链路思想,当服务端收到用户购买猪肉的请求时,我们可以构造出如下分支场景:
- controller层存在可能出口,即weight == null。据此生成测试用例A,命名为testBuyPorkIfWeightIsNull,实际入参中weight==null,期望接口抛出异常;
- 按链路进入到PigServiceImpl中,存在可能出口,即hasStore() == false。据此生成测试用例B,命名为testBuyPorkIfStorageIsShortage,实际入参中weight必需大于库存值(如代码中setup预设库存为10,虚拟用户请求了20),期望接口抛出异常;
- 按链路继续执行,发现正常出口。据此生成测试用例C,命名为testBuyPorkIfResultIsOk,实际入参中weight必须小于库存值(如代码中setup预设库存为10,虚拟用户请求了5),期望接口返回与入参相匹配的返回值一致,即正常返回了weight为5的猪肉打包实例。

单测代码
package com.alibaba.ut.demo.controller;
import com.alibaba.ut.demo.PorkController;
import com.alibaba.ut.demo.api.FactoryApi;
import com.alibaba.ut.demo.api.WareHouseApi;
import com.alibaba.ut.demo.dao.PorkStorageDao;
import com.alibaba.ut.demo.entity.PorkInst;
import com.alibaba.ut.demo.entity.PorkStorage;
import com.alibaba.ut.demo.exception.BaseBusinessException;
import com.alibaba.ut.demo.service.impl.PorkServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.mockito.stubbing.Answer;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;
/**
* @Author Taofu.lj
* @Version 1.0.0
* @Date 2021年12月02日 14:15
*/
@Slf4j
public class PorkControllerTest {
/**
* controller入口,由于是链路入口,无需用@Spy监听
*/
@InjectMocks
private PorkController porkController;
/**
* 接口类型的链路环节用实现类初始化代替, @Spy需要手动初始化避免initMocks时失败
* 注:链路上每一环都必须声明,即使测试用例中并没有被显性调用
*/
@InjectMocks
@Spy
private PorkServiceImpl porkService = new PorkServiceImpl();
/**
* 待Mock的链路环节,下同
*/
@Mock
private PorkStorageDao porkStorageDao;
@Mock
private FactoryApi factoryApi;
@Mock
private WareHouseApi wareHouseApi;
/**
* 预置数据可直接作为类变量声明
*/
private final Map< String, Object> mockParams = new HashMap< String, Object>() {{
put("user", "system_user");
}};
@Before
public void setup() {
// 必要: 初始化该类中所声明的Mock和InjectMock对象
MockitoAnnotations.initMocks(this);
// Mock预置数据并绑定相关方法(适用于有返回值的方法)
PorkStorage mockStorage = PorkStorage.builder().id(1L).cnt(10L).build();
// 常见Mock写法一:仅试图Mock返回值
when(porkStorageDao.queryStore()).thenReturn(mockStorage);
// 常见Mock写法二:不仅试图Mock返回值,还想额外打些日志方便定位
when(wareHouseApi.packagePork(any(), any()))
.thenAnswer(ans -> {
log.info("mock log can be written here");
return PorkInst.builder()
.weight(ans.getArgumentAt(0, Long.class))
.paramsMap(ans.getArgumentAt(1, Map.class))
.build();
});
// Mock动作并绑定相关方法(适用于无返回值方法)
doAnswer((Answer< Void>) invocationOnMock -> {
log.info("mock factory api success!");
return null;
}).when(factoryApi).supplyPork(any());
}
@After
public void teardown() {
// TODO: 可以加入Mock数据清理或资源释放
}
/**
* 当传入参数为null时,抛出业务异常
*
* @throws BaseBusinessException
*/
@Test(expected = BaseBusinessException.class)
public void testBuyPorkIfWeightIsNull() {
porkController.buyPork(null, mockParams);
}
/**
* 当后台库存不满足需求时,抛出业务异常
*
* @throws BaseBusinessException
*/
@Test(expected = BaseBusinessException.class)
public void testBuyPorkIfStorageIsShortage() {
porkController.buyPork(20L, mockParams);
}
/**
* 正常购买时返回业务结果
*/
@Test
public void testBuyPorkIfResultIsOk() {
Long expectWeight = 5L;
ResponseEntity< PorkInst> res = porkController.buyPork(expectWeight, mockParams);
// 此处第一次校验接口返回状态是否符合预期
Assert.assertEquals(HttpStatus.OK, res.getStatusCode());
Long actualWeight = Optional.of(res).map(HttpEntity::getBody).map(PorkInst::getWeight).orElse(-99L);
// 此处第二次校验接口返回值是否符合预期
Assert.assertEquals(expectWeight, actualWeight);
}
}

本文为阿里云原创内容,未经允许不得转载。
基于链路思想的SpringBoot单元测试快速写法的更多相关文章
- IDEA中SpringBoot项目快速创建单元测试
如何在IDEA中对于SpringBoot项目快速创建单元测试 创建测试用例 右键需要进行测试的方法,选择GO TO然后选择Test 点击Create New Test 勾选需要创建单元测试的方法 然后 ...
- 基于快速排序思想partition查找第K大的数或者第K小的数。
快速排序 下面是之前实现过的快速排序的代码. function quickSort(a,left,right){ if(left==right)return; let key=partition(a, ...
- 玩转 SpringBoot 2 快速整合 | JSP 篇
前言 JavaServer Pages(JSP)技术使Web开发人员和设计人员能够快速开发和轻松维护利用现有业务系统的信息丰富的动态Web页面. 作为Java技术系列的一部分,JSP技术可以快速开发独 ...
- Springboot单元测试Junit深度实践
Springboot单元测试Junit深度实践 前言 单元测试的好处估计大家也都知道了,但是大家可以发现在国内IT公司中真正推行单测的很少很少,一些大厂大部分也只是在核心产品推广单测来保障质量,今天这 ...
- .Net Core WebAPI 基于Task的同步&异步编程快速入门
.Net Core WebAPI 基于Task的同步&异步编程快速入门 Task.Result async & await 总结 并行任务(Task)以及基于Task的异步编程(asy ...
- 关于 "Context" 模式(基于COM思想IUnknown思想)
有同事很喜欢用Context模式,觉得是自己"首创", 我有些自己的想法, 或者大家可以发表下自己的观点. 什么是Context模式? 23种设计模式中没有这个模式, 是同事自 ...
- 基于Proxy思想的Android插件框架
意义 研究插件框架的意义在于下面几点: 减小安装包的体积,通过网络选择性地进行插件下发 模块化升级.减小网络流量 静默升级,用户无感知情况下进行升级 解决低版本号机型方法数超限导致无法安装的问题 代码 ...
- 使用Springboot + Gradle快速整合Mybatis-Plus
使用Springboot + Gradle快速整合Mybatis-Plus 作者:Stanley 罗昊 [转载请注明出处和署名,谢谢!] MyBatis-Plus(简称 MP)是一个 MyBatis ...
- 基于Visual Studio .NET2015的单元测试
基于Visual Studio .NET2015的单元测试 1. 在Visual Studio .NET2015中创建任意项目. 2. 在某个公共类的公共方法的名称上面点击右键,选择“创建 ...
- 基于链路的OSPFMD5口令认证
实验要求:掌握基于链路的OSPFMD5口令认证 拓扑如下: 配置如下: R1enable configure terminal interface s0/0/0ip address 192.168.1 ...
随机推荐
- union all 优化案例
遇到个子查询嵌套 UNION ALL 的SQL语句很慢,谓词过滤条件不能内推进去,需要优化这段 UNION ALL这块的内容. UNION ALL 慢SQL: SELECT * FROM ((SELE ...
- 开源推荐|简洁且强大的开源堡垒机OneTerm
在运维的日常工作中,登陆服务器操作不可避免,为了更安全的管控服务器,但凡有点规模的公司都会上线堡垒机系统,堡垒机能够做到事前授权.事中监控.事后审计,同时也可以满足等保合规要求.提到堡垒机,大伙第一时 ...
- 大模型时代的PDF解析工具
去年(2023年)是大模型爆发元年.但是大模型具有两个缺点:缺失私有领域知识和幻觉.缺失私有领域知识是指大模型训练时并没有企业私有数据/知识,所以无法正确回答相关问题.并且在这种情况下,大模型会一本正 ...
- ElasticSearch分页查询的实现
1.设置mapping PUT /t_order { "settings": { "number_of_shards": 1, "number_of_ ...
- WPF自定义Panel:让拖拽变得更简单
在 WPF 应用程序中,拖放操作是实现用户交互的重要组成部分.通过拖放操作,用户可以轻松地将数据从一个位置移动到另一个位置,或者将控件从一个容器移动到另一个容器.然而,WPF 中默认的拖放操作可能并不 ...
- KingbaseES 扩展插件src_restrict 介绍
插件简介 src_restrict是KingbaseES的一个扩展插件,主要用于支持来源限制功能,该功能通过黑白名单来实现.插件src_restrict默认已经加载. 查看插件是否加载 show sh ...
- debian12 出现Waiting for suspend/resume device ... Begin: Running /scripts/local-block ... done.
/etc/initramfs-tools/conf.d/resume里对应的交换分区的uuid不正确 删除/etc/initramfs-tools/conf.d/resume 再运行 sudo upd ...
- Java 实现OCR扫描/识别图片文字
图片内容一般无法编辑,如果想要读取图片中的文本,我们需要用到OCR工具.本文将介绍如何在Java中实现OCR识别读取图片中的文字. 所需工具: IDEA Spire.OCR for Java - Ja ...
- windows下安装SASS
window下安装ruby与sass(附ruby) webstorm设置sass自动编译,及参数配置 安装sass以及如何在sublime里使用 sublime text 3 配置sass环境 sub ...
- #树形dp#洛谷 1272 重建道路
题目 给出一个大小为 \(n\) 的树, 问至少断掉多少条边使得存在一个大小为 \(m\) 的连通块 \(n\leq 150\) 分析 设 \(dp[x][s]\) 表示以 \(x\) 为根的子树至少 ...