基于链路思想的SpringBoot单元测试快速写法
一 为什么要写单元测试?
二 为什么推荐链路思想?
应该如何设计测试用例? 应该如何编写测试用例? 测试用例的质量该如何判定?
三 如何用链路思想设计/构造单测?
四 快速写法实践案例
1 快速写法的核心步骤有哪些?
设计测试用例的输入与预期输出
确定链路上的全部Mock点
收集Mock点的模拟返回数据
2【开发篇】真实用户买猪
业务对象
/**
* 猪肉库存的数据库实体类
*/
publicclassPorkStorage{
privateLong id;
privateLong cnt;
}
/**
* 猪肉实例,由仓库打包后生成
**/
publicclass PorkInst {
/**
* 重量
*/
private Long weight;
/**
* 附件参数,例如包装类型,寄送地址等信息
*/
private Map<String, Object> paramsMap;
}
业务代码
publicclassPorkController{
private PorkService porkService;
public ResponseEntity<PorkInst> buyPork(Long weight,
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));
}
}
publicinterfacePorkService{
/**
* 获取猪肉打包实例
*
* @param weight 重量
* @param params 额外信息
* @return {@link PorkInst} - 指定数量的猪肉实例
* @throws BaseBusinessException 如果猪肉库存不足,返回异常,同时后台告知工厂
*/
PorkInst getPork(Long weight, Map<String, Object> params);
}
publicinterfacePorkStorageDaoextendsBaseMapper<PorkStorage> {
PorkStorage queryStore();
}
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mappernamespace="com.alibaba.ut.demo.dao.PorkStorageDao">
<sqlid="columns">id, cnt</sql>
<sqlid="table_name">pork_storage</sql>
<selectid="queryStore"resultType="com.alibaba.ut.demo.entity.PorkStorage">
select
<includerefid="columns"/>
from
<includerefid="table_name"/>
where id = 1
</select>
</mapper>
publicinterfaceFactoryApi {
voidsupplyPork(Long weight);
}
4j
publicclassFactoryApiImplimplementsFactoryApi{
publicvoidsupplyPork(Long weight){
log.info("call real factory to supply pork, weight: {}", weight);
}
}
publicinterfaceWareHouseApi {
PorkInst packagePork(Long weight, Map<String, Object> params);
}
4j
publicclassWareHouseApiImplimplementsWareHouseApi{
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【单测篇】虚拟用户买猪
单测依赖
<!-- 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
- 非Mock点方法:对于链路中非入口的环节(通常将controller作为入口,其他方法即为非入口),需要标注@Spy以声明该对象在单测链路中为监听状态,即需要正常走完流程。此处根据方法内是否引用Mock点方法进一步分成两类。
该方法内引用了其他Mock点方法,需要在@Spy的基础上额外标注@InjectMocks,声明该对象在单测链路中需要被注入其他Mock对象。 该方法内未引用其他Mock点方法,无需进行其他操作。
- Mock点方法:标注@Mock以声明该对象在单测链路中需要被Mock,可以通过org.mockito.Mockito类内的一系列static方法手动注入Mock值(ep. when(A()).thenReturn(B))。
3. 编写单测用例主体。在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
publicclassPorkControllerTest{
/**
* controller入口,由于是链路入口,无需用@Spy监听
*/
@InjectMocks
privatePorkController porkController;
/**
* 接口类型的链路环节用实现类初始化代替, @Spy需要手动初始化避免initMocks时失败
* 注:链路上每一环都必须声明,即使测试用例中并没有被显性调用
*/
@InjectMocks
@Spy
privatePorkServiceImpl porkService = new PorkServiceImpl();
/**
* 待Mock的链路环节,下同
*/
@Mock
privatePorkStorageDao porkStorageDao;
@Mock
privateFactoryApi factoryApi;
@Mock
privateWareHouseApi wareHouseApi;
/**
* 预置数据可直接作为类变量声明
*/
privatefinalMap<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");
returnPorkInst.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);
}
}
关键词
系统
场景
技术
最新评论
推荐文章
作者最新文章
你可能感兴趣的文章
Copyright Disclaimer: The copyright of contents (including texts, images, videos and audios) posted above belong to the User who shared or the third-party website which the User shared from. If you found your copyright have been infringed, please send a DMCA takedown notice to [email protected]. For more detail of the source, please click on the button "Read Original Post" below. For other communications, please send to [email protected].
版权声明:以上内容为用户推荐收藏至CareerEngine平台,其内容(含文字、图片、视频、音频等)及知识版权均属用户或用户转发自的第三方网站,如涉嫌侵权,请通知[email protected]进行信息删除。如需查看信息来源,请点击“查看原文”。如需洽谈其它事宜,请联系[email protected]。
版权声明:以上内容为用户推荐收藏至CareerEngine平台,其内容(含文字、图片、视频、音频等)及知识版权均属用户或用户转发自的第三方网站,如涉嫌侵权,请通知[email protected]进行信息删除。如需查看信息来源,请点击“查看原文”。如需洽谈其它事宜,请联系[email protected]。