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. 快速写法
- 快速写法的入口是controller层方法,这样对于controller层存在的少量逻辑代码也能做到覆盖。
- 设计测试用例的目的不仅仅是跑通主流程,而是要跑通全部可能的流程,即所谓的分支全覆盖,因此设计用例的输入与输出尤为重要。即便是新增分支的增量修改(例如加了一行if-else),也需要补充相应的输入与预期输出。非常不建议根据单测运行结果修改预期结果,这说明原先的代码设计有问题。
- 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 {
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;
@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() { MockitoAnnotations.initMocks(this);
PorkStoragePO mockStorage = new PorkStoragePO().setId(1L).setCnt(10L);
when(porkStorageDao.queryStore()).thenReturn(mockStorage);
when(wareHouseApi.packagePork(any(), any())) .thenAnswer(ans -> { log.info("mock log can be written here"); return new PorkInstBO() .setWeight(0L) .setParamsMap(new HashMap<>()); });
doAnswer((Answer<Void>) invocationOnMock -> { log.info("mock factory api success!"); return null; }).when(factoryApi).supplyPork(any()); }
@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