引言:
本文更偏向实践而非方法论,所提及的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的环节涂灰,从而变成我们在编写单元测试用例时所需要的“虚拟用户买猪”图。
快速写法的入口是controller层方法,这样对于controller层存在的少量逻辑代码也能做到覆盖。
设计测试用例的目的不仅仅是跑通主流程,而是要跑通全部可能的流程,即所谓的分支全覆盖,因此设计用例的输入与输出尤为重要。即便是新增分支的增量修改(例如加了一行if-else),也需要补充相应的输入与预期输出。非常不建议根据单测运行结果修改预期结果,这说明原先的代码设计有问题。
Mock点的判断依据是链路上该环节是否依赖第三方服务。强烈建议在设计前画出大概的功能流程图(如”用户买猪“图),这可以大大提高确定Mock点的速度和准确性。
确定Mock点后,我们就需要构造相应的模拟返回数据。Mock数据需要考虑多个因素: a. 是否与api层对应方法的期望返回值匹配: 不能把从猪厂返回的Mock数据用牛肉替代 b. 是否与模拟输入数据匹配:用户需要1斤猪肉,不能返回5斤猪肉的数据 c. 是否与api层的所有分支匹配:部分api层会对返回值进行响应码(2xx || 3xx || 4xx)校验,这类场景便需要构造不同响应码的Mock数据
该项目基于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));
}
}
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();
}
}
对于PandoraBoot工程,可参考下文的Maven配置引入相关依赖。对于非PandoraBoot工程,仅需引入Junit和Mockito两个包即可。注本章所提到的单测写法默认Mock Dao层且无需启动容器应用。如果不想Mock Dao层,建议在依赖中引入H2这类内存型数据库,同时支持本地启动容器应用。
<!-- test -->
<dependency>
<groupId>com.taobao.pandora</groupId>
<artifactId>pandora-boot-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.10.19</version>
<scope>test</scope>
</dependency>
在阅读下面的内容前,强烈建议先学习Junit和Mockito的基本用法和运行原理,包括但不限于下文写法中可能涉及的注解:
Junit原生流Method注解:@Before 、@Test、@After Mockito原生Field注解:@Mock、@InjectMocks、@Spy
在已知待单测业务链路的前提下,写法可以简要归纳为以下几步:
1 . 初步设计单测用例框架。包括setup、teststep、teardown三步,setup负责处理一些全局必要的单测前置逻辑(例如Mock数据插入和环境准备),teststep承载单测用例的主体(要求以Assert类近似的断言语句为结尾),teardown负责处理一些全局必要的收尾逻辑(例如Mock数据删除和环境释放)
2 . 声明并初始化用例所涉及的所有链路环节。在已知链路流程的前提下,所有环节都可以依据是否为Mock点方法大致分为两类(参考上文中"用户买猪"图的灰、白点)。
ⅰ. 该方法内引用了其他Mock点方法,需要在@Spy的基础上额外标注@InjectMocks,声明该对象在单测链路中需要被注入其他Mock对象。
ⅱ. 该方法内未引用其他Mock点方法,无需进行其他操作。
3. 编写单测用例主体。在teststep中从controller层发起方法调用,最终通过Assert断言结果判断用例的成功与否。除了普通的返回值校验场景外,Junit也支持用@Test(expected = xxException.class)来声明该用例期望发生的异常类型。最后还是建议写完单测后能够以注释的形式说明该单测所支持的场景和预期结果的大致说明,方便以后自己和其他接手的同学能够快速了解这个单测用例的相关信息。
这里仍以"用户买猪"的场景为例,依照链路思想,当服务端收到用户购买猪肉的请求时,我们可以构造出如下分支场景:
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);
}
}
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/1mKFB8Iutm6SFE-7v_fVtA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。