基于链路思想的 SpringBoot 单元测试快速写法
2025-01-22 08:19:30    2.8k 字   
This post is also available in English and alternative languages.

基于链路思想的SpringBoot单元测试快速写法。本篇酌情删改,推荐阅读原文。

测试是 Devops 上极重要的一环,但大多数开发的眼光都停留在集成测试这一环:“只要能联调成功,那么我这次准备上线的特性一定是没问题的”。


1. 链路思想

深入接触单元测试,开发难免会遇到以下场景:

  • 如何设计测试用例?
  • 如何编写测试用例?
  • 测试用例的质量该如何判定?

刚开始写单元测试,也曾参考并尝试过网上五花八门的写法。这些写法可能用到了不同的单测框架,也可能侧重了不同的代码环节(例如特定的某个service方法)。一开始为能够熟练使用多种单测框架而沾沾自喜,但随着工作的推进,逐渐意识到,单元测试中重要的并不是某个框架选型,而是如何设计一套优秀的用例。之所以用「一套」而不是"一个",是因为在业务代码中,逻辑往往并非"一帆风顺",有许多 if-else 会妆点我们的业务代码。显然对于这类业务代码,"一个"测试用例无法完全满足所有可能出现的场景。如果为了偷懒,尝试仅仅用"一个"用例去覆盖主流程,无异于给自己埋了个雷:“线上场景可不是"一个"用例这么简单”。

于是开始专注于测试用例的设计,从输入/输出开始,重新审视曾经开发过的代码。如果将某个 Controller 方法作为入口,那这一套业务流程可以当做一条链路,而上下文中所关联的 Service层、DAO层、API层的各方法都可以作为链路上的各环节。通过绘制链路图,将各环节根据是否关联外部系统大致分成黑、白两类,整套业务流程和各环节的潜在分支便会变得清晰,测试用例便从"一个"自然而然地变成了"一套"。此处多提一嘴,链路思想设计用例的基础是结构清晰、圈复杂度可控制的代码风格,如果开发的时候依然尊崇"论文式"、“一刀流”,在单个方法内"长篇大论",那链路式将是一个巨大的负担。

在测试框架选型上,更习惯于 Junit + Mockito 的组合,原因仅仅是熟悉与简单,且参考文档比比皆是。如果各位已经有自己习惯的框架和写法,也不必照搬本文所提及的东西,毕竟单测是为了better code,而不是自找麻烦。

但无论测试用例如何设计或是如何编写,我始终认为,在不考虑测试代码的风格和规范的前提下,衡量测试用例质量的核心指标是分支覆盖率。这也是我推荐链路思想的一大原因:「从入口出发,遍历链路上各个环节的各个分支,遇到阻碍就Mock」。相比于分别单测各个独立方法,单测链路所需要的入参和出参更加清晰,更是大大节省了编写测试代码所需的时间成本!


2. 如何设计/构造

大家比较熟悉的链路概念应该是全链路压测。

全链路压测简单来说,就是基于实际的生产业务场景、系统环境,模拟海量的用户请求和数据对整个业务链进行压力测试,并持续调优的过程,本质上也是性能测试的一种手段。

如果将完整的业务流程视作全链路,那作为业务链上的一环,即某个后端服务,它其实也是一个微链路。这里以自上而下的开发流程为例,对于新增的功能接口,我们会习惯性地由 Controller 开始设计,然后构建 Service层、DAO层、API层,最后再锦上添花地加些AOP。如果以链路思想,将复杂的流程拆成各个链路的各个环节,那这样的代码功能清晰,维护起来也相当方便。

我非常认同"限制单个方法行数<=50 的代码门禁",对于长篇大论的代码"论文",想必没有哪位接手的同学脸上能露出笑容的;针对这类代码,我认为 clean code 的优先级比补充单测用例更高,连逻辑都无法理清,即便硬着头皮写出单测用例,后续的调试和维护工作量也是不可预料的(试想,假如后面有位A同学接手了这块代码,他在“论文”中加了xx行导致ut失败了,他该如何去定位问题)。


简单画个图来阐述下我的观点。这是一张"用户买猪"的功能逻辑图。以链路思想,开发人员将整套流程拆分为相应的链路环节,涵盖了Controller、Service、DAO、API各层,整条链路清晰明了,只要搭配完善的上下文日志,定位线上问题亦是轻而易举。

用户买猪

当然在补充单测用例时,同样也能用链路思想来构造测试用例。测试用例的要求很简单,需要覆盖 Controller、Service 等自主编写的代码(多分支场景也需要完全覆盖),对于周边关联的系统可以采用 Mock 进行屏蔽,对于DAO层的 SQL 可以视需求决定是否 Mock。秉承这个思路,可以对"用户买猪"图(上图)进行改造,将允许 Mock 的环节涂灰,从而变成在编写单元测试用例时所需要的"虚拟用户买猪"(下图)。

虚拟用户买猪

3. 快速写法

  1. 快速写法的入口是controller层方法,这样对于controller层存在的少量逻辑代码也能做到覆盖。
  2. 设计测试用例的目的不仅仅是跑通主流程,而是要跑通全部可能的流程,即所谓的分支全覆盖,因此设计用例的输入与输出尤为重要。即便是新增分支的增量修改(例如加了一行if-else),也需要补充相应的输入与预期输出。非常不建议根据单测运行结果修改预期结果,这说明原先的代码设计有问题。
  3. Mock点的判断依据是链路上该环节是否依赖第三方服务。强烈建议在设计前画出大概的功能流程图(如”用户买猪“图),这可以大大提高确定Mock点的速度和准确性。

3.1. 模拟业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 猪肉实例,由仓库打包后生成
@Data
@Accessors(chain = true)
public class PorkInstBO {
/**
* 重量
*/
private Long weight;
/**
* 附件参数,例如包装类型,寄送地址等信息
*/
private Map<String, Object> paramsMap;
}

// 猪肉库存的数据库实体类
@Data
@Accessors(chain = true)
@TableName(value = "pork_storage", autoResultMap = true)
public class PorkStoragePO {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long cnt;
}
1
2
3
4
5
@Mapper
public interface PorkStorageDao extends BaseMapper<PorkStoragePO> {
@Select("select `id`, `cnt` from `pork_storage` where `id` = 1")
PorkStoragePO queryStore();
}
1
2
3
4
5
6
7
8
9
10
11
12
public interface FactoryApi {
void supplyPork(Long weight);
}

@Slf4j
@Service
public class FactoryApiImpl implements FactoryApi {
@Override
public void supplyPork(Long weight) {
log.info("call real factory to supply pork, weight: {}", weight);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public interface PorkService {
/**
* 获取猪肉打包实例
* <p>
* 如果猪肉库存不足,返回异常,同时后台告知工厂
*
* @param weight 重量
* @param params 额外信息
* @return 指定数量的猪肉实例
*/
PorkInstBO getPork(Long weight, Map<String, Object> params);
}

@Slf4j
@Service
public class PorkServiceImpl implements PorkService {
@Resource
private PorkStorageDao porkStorageDao;
@Override
public PorkInstBO getPork(Long weight, Map<String, Object> params) {
log.info("----- getPork -----");
PorkStoragePO result = porkStorageDao.queryStore();
return new PorkInstBO().setWeight(result.getCnt()).setParamsMap(new HashMap<>());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public interface WareHouseApi {
PorkInstBO packagePork(Long weight, Map<String, Object> params);
}

@Slf4j
@Service
public class WareHouseApiImpl implements WareHouseApi {
@Override
public PorkInstBO packagePork(Long weight, Map<String, Object> params) {
log.info("call real warehouse to package, weight: {}", weight);
return new PorkInstBO().setWeight(weight).setParamsMap(params);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@RestController
@RequestMapping("/pork")
public class PorkController {
@Resource
private PorkService porkService;
@PostMapping("/buy")
public ResponseEntity<PorkInstBO> buyPork(@RequestParam("weight") Long weight,
@RequestBody Map<String, Object> params) {
if (weight == null) {
throw new RuntimeException("invalid input: weight[{" + weight + "}]");
}
return ResponseEntity.ok(porkService.getPork(weight, params));
}
}

3.2. 链路单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@Slf4j
public class PorkControllerTest {
@InjectMocks
private PorkController porkController;
/**
* 接口类型的链路环节用实现类初始化代替, @Spy需要手动初始化避免initMocks时失败
* 注:链路上每一环都必须声明,即使测试用例中并没有被显性调用
*/
@InjectMocks
private PorkServiceImpl porkService = spy(PorkServiceImpl.class);
@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预置数据并绑定相关方法(适用于有返回值的方法)
PorkStoragePO mockStorage = new PorkStoragePO().setId(1L).setCnt(10L);

// 常见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 new PorkInstBO()
.setWeight(0L)
.setParamsMap(new HashMap<>());
});

// Mock动作并绑定相关方法(适用于无返回值方法)
doAnswer((Answer<Void>) invocationOnMock -> {
log.info("mock factory api success!");
return null;
}).when(factoryApi).supplyPork(any());
}

/**
* 当传入参数为null时,抛出业务异常
*/
@Test(expected = Exception.class)
public void testBuyPorkIfWeightIsNull() {
porkController.buyPork(null, mockParams);
}

/**
* 当后台库存不满足需求时,抛出业务异常
*/
@Test(expected = Exception.class)
public void testBuyPorkIfStorageIsShortage() {
porkController.buyPork(20L, mockParams);
}

/**
* 正常购买时返回业务结果
*/
@Test
public void testBuyPorkIfResultIsOk() {
Long expectWeight = 5L;
ResponseEntity<PorkInstBO> res = porkController.buyPork(expectWeight, mockParams);
// 此处第一次校验接口返回状态是否符合预期
Assert.assertEquals(HttpStatus.OK, res.getStatusCode());

Long actualWeight = Optional.of(res).map(HttpEntity::getBody).map(PorkInstBO::getWeight).orElse(-99L);
// 此处第二次校验接口返回值是否符合预期
Assert.assertEquals(expectWeight, actualWeight);
}
}

3.3. 依赖POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.8.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.8.0</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.10.20</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.9.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.6</version>
<scope>test</scope>
</dependency>

4. Reference