一 为什么要写单元测试?
二 为什么推荐链路思想?
-
应该如何设计测试用例? -
应该如何编写测试用例? -
测试用例的质量该如何判定?
三 如何用链路思想设计/构造单测?
四 快速写法实践案例
1 快速写法的核心步骤有哪些?
设计测试用例的输入与预期输出
确定链路上的全部Mock点
收集Mock点的模拟返回数据
2【开发篇】真实用户买猪
业务对象
/** * 猪肉库存的数据库实体类 */public class PorkStorage { private Long id;
private Long cnt;}
/** * 猪肉实例,由仓库打包后生成 **/public class PorkInst { /** * 重量 */ private Long weight;
/** * 附件参数,例如包装类型,寄送地址等信息 */ private Map<String, Object> paramsMap;}
业务代码
public class PorkController { 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)); }}
PorkService.java
public interface PorkService { /** * 获取猪肉打包实例 * * @param weight 重量 * @param params 额外信息 * @return {@link PorkInst} - 指定数量的猪肉实例 * @throws BaseBusinessException 如果猪肉库存不足,返回异常,同时后台告知工厂 */ PorkInst getPork(Long weight, Map<String, Object> params);}
public interface PorkStorageDao extends BaseMapper<PorkStorage> { PorkStorage queryStore();}
"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>
public interface FactoryApi { void supplyPork(Long weight);}
4jpublic class FactoryApiImpl implements FactoryApi { public void supplyPork(Long weight) { log.info("call real factory to supply pork, weight: {}", weight); }}
public interface WareHouseApi { PorkInst packagePork(Long weight, Map<String, Object> params);}
4jpublic class WareHouseApiImpl implements WareHouseApi { 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 */@Slf4jpublic 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); }}
推荐阅读:
单元测试的意义是什么?:
https://www.zhihu.com/question/49530527
Better code, faster: 8 reasons why you should use unit testing :https://fortegrp.com/the-importance-of-unit-testing/
阿里云云原生携10+技术专家带来《云原生与云未来的新可能》
2021年12月,阿里云携10+技术专家亮相年度顶级云原生开源技术峰会 KubeCon + CloudNa tiveCon + Open Source Summit China 2021,并带来阿里云云原生专场,不仅汇聚行业发展方向的精彩主题演讲,在云基础设施、可观察性、存储、定制和扩展 Kubernetes、性能、服务网格、无服务器、容器运行时、CI/CD、网络等云原生与开源技术等各大专题中,从阿里云真实业务场景中走出来的云原生技术最佳实践也向全球开发者一一呈现。
如果说云原生代表了云计算的今天,那么云计算的未来会是什么样?我们将本次阿里云云原生专场的技术专家们分享内容实录汇集本书,希望与更多的开发者共同探索“云未来,新可能”。
点击阅读原文查看详情。