有个人做网站的,个人特种证件查询网站,代理网址网站,网站死链接怎么处理简介#xff1a;本文更偏向实践而非方法论#xff0c;所提及的SpringBoot单元测试写法亦并非官方解#xff0c;仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格#xff0c;只要能实现单元测试的效果#xff0…简介本文更偏向实践而非方法论所提及的SpringBoot单元测试写法亦并非官方解仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格只要能实现单元测试的效果就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得帮助像笔者这样的新人快速成长。 作者 | 桃符 来源 | 阿里技术公众号
引言
本文更偏向实践而非方法论所提及的SpringBoot单元测试写法亦并非官方解仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格只要能实现单元测试的效果就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得帮助像笔者这样的新人快速成长。
一 为什么要写单元测试
测试是Devops上极重要的一环但大多数开发的眼光都停留在集成测试这一环——只要能联调成功那么我这次准备上线的特性一定是没问题的。
老实承认我曾经是这样的可能现在也还是这样。作为非科班出身的笔者研究生毕业后就立即进入了同在杭州的xx厂先后参与了内部Devops平台建设和xx云Paas项目开荒在这两个项目中开发 测试是很正常的场景甚至部分测试也是原开发友情客串的由于缺少专业的测试人员开发往往需要兼顾集成测试甚至是线上测试的活儿。为了提高效率我将一部分常用的测试用例维护在了内部的自动化测试平台上。即便如此我仍能清晰地感觉到测试所能覆盖的场景屈指可数以至于每次自信地上线大特性后都会因一些奇怪的问题而定位到大半夜。幸亏后面遇到了一位资深大佬在code review时他直接点出我不写单元测试的坏习惯并用自身惨痛的线上教训反复强调单测的重要性。
当然上述只是我的亲身经历勉强作为日常闲聊的谈资。如果想要深入理解单元测试的重要性推荐Google上搜索the importance of unit test关键字可以感受下不同国家、不同领域的程序员对单元测试的不同理解想必能有更大的收获。
二 为什么推荐链路思想
深入接触单元测试开发难免会遇到以下场景
应该如何设计测试用例应该如何编写测试用例测试用例的质量该如何判定
刚开始学习写单元测试我也曾参考并尝试过网上五花八门的写法。这些写法可能用到了不同的单测框架也可能侧重了不同的代码环节(例如特定的某个service方法)。一开始我为自己能够熟练使用多种单测框架而沾沾自喜但随着工作的推进我逐渐意识到单元测试中重要的并不是框架选型而是如何设计一套优秀的用例。之所以用一套而不是一个是因为在我们的业务代码中逻辑往往并非一帆风顺有许多if-else会妆点我们的业务代码。显然对于这类业务代码一个测试用例无法完全满足所有可能出现的场景。如果为了偷懒尝试仅仅用一个用例去覆盖主流程无异于给自己埋了个雷——线上场景可没一个用例这么简单
我开始专注于测试用例的设计从输入输出开始重新审视曾经开发过的代码。我发现如果将某个controller方法作为入口那这一套业务流程可以当做一条链路而上下文中所关联的service层、dao层、api层的各方法都可以作为链路上的各环节。通过绘制链路图将各环节根据是否关联外部系统大致分成黑、白两类整套业务流程和各环节的潜在分支便会变得清晰测试用例便从一个自然而然地变成了一套。此处多提一嘴链路思想设计用例的基础是结构清晰、圈复杂度可控制的代码风格如果开发的时候依然尊崇论文式、一刀流在单个方法内长篇大论那链路式将是一个巨大的负担。
编写测试用例其实不是一件费劲的事对于深耕业务代码的开发而言编写测试用例便像是做一盘小菜举手可为。于我而言如今写测试用例所花费的时间甚至没有设计测试用例的时间长(凸显用例设计的重要性但也有可能是我对测试用例的设计还不够熟练)。在测试框架选型上我更习惯于JunitMockito的组合原因仅仅是熟悉与简单且参考文档比比皆是。如果各位已经有自己习惯的框架和写法也不必照搬本文所提及的东西毕竟单测是为了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 {Autowiredprivate 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 version1.0 encodingUTF-8?!DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//ENhttp://mybatis.org/dtd/mybatis-3-mapper.dtdmapper namespacecom.alibaba.ut.demo.dao.PorkStorageDao sql idcolumnsid, cnt /sql sql idtable_namepork_storage /sql select idqueryStore resultTypecom.alibaba.ut.demo.entity.PorkStorageselect include refidcolumns/from include refidtable_name/where id 1 /select/mapper
FactoryApi.java
public interface FactoryApi {void supplyPork(Long weight);
}
FactoryApiImpl.java
Service
Slf4j
public class FactoryApiImpl implements FactoryApi {Overridepublic 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 {Overridepublic 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实际入参中weightnull期望接口抛出异常按链路进入到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监听*/InjectMocksprivate PorkController porkController;/*** 接口类型的链路环节用实现类初始化代替, Spy需要手动初始化避免initMocks时失败* 注链路上每一环都必须声明即使测试用例中并没有被显性调用*/InjectMocksSpyprivate PorkServiceImpl porkService new PorkServiceImpl();/*** 待Mock的链路环节下同*/Mockprivate PorkStorageDao porkStorageDao;Mockprivate FactoryApi factoryApi;Mockprivate WareHouseApi wareHouseApi;/*** 预置数据可直接作为类变量声明*/private final Map String, Object mockParams new HashMap String, Object() {{put(user, system_user);}};Beforepublic 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());}Afterpublic 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);}/*** 正常购买时返回业务结果*/Testpublic 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);}
}
原文链接
本文为阿里云原创内容未经允许不得转载。