这里是weihubeats,觉得文章不错可以关注公众号小奏技术,文章首发。拒绝营销号,拒绝标题党
背景
目前我们团队测试资源的缺乏,大部分开发人员测试的随意性,导致上线的错误率偏高,让系统稳定性处于较低水平,基于这一现状决定引入单元测试以提高系统稳定性
项目紧,没时间就不用写单测了吗
这是我们多数人不写单测的理由也是借口。但是我们要知道错误率是恒定的,需要的调试量也是固定的,用测试甚至线上环境调试并不能降低调试的量,只会降低调试效率。
如果说项目紧不写单测,看起来编码阶段省了一些时间,但如果存在问题,必然会在测试和线上花掉成倍甚至更多的成本来修复
单元测试与集成测试的区别
- 单元测试: 又称模块测试,是针对软件设计的最小单位——程序模块进行正确性检验的测试工作
- 集成测试: 也叫做组装测试。通常在单元测试的基础上,将所有的程序模块进行有序的、递增的测试。集成测试是检验程序单元或部件的接口关系,逐步集成为符合概要设计要求的程序部件或整个系统
像我们通常使用spirng boot 注解 @SpringBootTest 就属于集成测试的一种
单元测试可以带来的好处
- 提升软件质量
优质的单元测试可以保障开发质量和程序的鲁棒性。越早发现的缺陷,其修复的成本越低。 - 促进代码优化
单元测试的编写者和维护者都是开发工程师,在这个过程当中开发人员会不断去审视自己的代码,从而(潜意识)去优化自己的代码。 - 提升研发效率
编写单元测试,表面上是占用了项目研发时间,但是在后续的联调、集成、回归测试阶段,单测覆盖率高的代码缺陷少、问题已修复,有助于提升整体的研发效率。 - 增加重构自信
代码的重构一般会涉及较为底层的改动,比如修改底层的数据结构等,上层服务经常会受到影响;在有单元测试的保障下,我们对重构出来的代码会多一份底气
目前主流的mock(单元测试)框架
实际调研过程
最开始是准备选用testable-mock ,因为是阿里开源的,使用起来也是非常轻量和简单的。 由于以下原因直接放弃了这个mock框架
- 测试demo跑不起来
社区小伙伴给的原因是jdk版本高于8就会有问题。至于如何解决就没有去深入研究了
- 项目进入维护状态
- 作者自己对于该项目的一些否定
为什么不用最流行的mockito
Mockito 目前在github上的star是最多的。但是看了下感觉这个mock框架平平无奇,比较常规,单元测试代码需要编写较多的Java代码。
在实际调研过程中最终发现美团使用的单元测试框架为Spock,觉得挺不错的
主要有亮点:
- 可以用更少的代码去实现单元测试
- 有更好的语义化,让你的单测代码可读性更高
。所以最终选用Spock Spock是基于Groovy语言编写测试用例。既然编写单元测试无法避免,就让写单元测试变得好玩一点。同时也让大家能够多学一门语言,扩展自己的技能,在高速内卷的行业更有竞争力。同时Groovy语言和Java本身没什么特别大的差异,上手难度比较低,学习成本也不算特别高
Groovy简单培训
由于Spock 是基于Groovy 语言编写的,所以我们这里先大致了解下Groovy 语法
思维导图链接
Spock如何解决单元测试开发中的痛点
- 测试多条件分支
public double calc(double income) {
BigDecimal tax;
BigDecimal salary = BigDecimal.valueOf(income);
if (income <= 0) {
return 0;
}
if (income > 0 && income <= 3000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.03);
tax = salary.multiply(taxLevel);
} else if (income > 3000 && income <= 12000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.1);
BigDecimal base = BigDecimal.valueOf(210);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 12000 && income <= 25000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.2);
BigDecimal base = BigDecimal.valueOf(1410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 25000 && income <= 35000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.25);
BigDecimal base = BigDecimal.valueOf(2660);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 35000 && income <= 55000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.3);
BigDecimal base = BigDecimal.valueOf(4410);
tax = salary.multiply(taxLevel).subtract(base);
} else if (income > 55000 && income <= 80000) {
BigDecimal taxLevel = BigDecimal.valueOf(0.35);
BigDecimal base = BigDecimal.valueOf(7160);
tax = salary.multiply(taxLevel).subtract(base);
} else {
BigDecimal taxLevel = BigDecimal.valueOf(0.45);
BigDecimal base = BigDecimal.valueOf(15160);
tax = salary.multiply(taxLevel).subtract(base);
}
return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}
@Unroll
def "个税计算,收入:#income, 个税:#result"() { expect: "when + then 的组合"
CalculateTaxUtils.calc(income) == result
where: "表格方式测试不同的分支逻辑"
income || result
-1 || 0
0 || 0
2999 || 89.97
3000 || 90.0
3001 || 90.1
11999 || 989.9
12000 || 990.0
12001 || 990.2
24999 || 3589.8
25000 || 3590.0
25001 || 3590.25
34999 || 6089.75
35000 || 6090.0
35001 || 6090.3
54999 || 12089.7
55000 || 12090
55001 || 12090.35
79999 || 20839.65
80000 || 20840.0
80001 || 20840.45
}
Spock和JUnit对比同一份单元测试的语法差异
public StudentVO getStudentById(int id) {
List<StudentDTO> students = studentDao.getStudentInfo();
StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
StudentVO studentVO = new StudentVO();
if (studentDTO == null) {
return studentVO;
}
studentVO.setId(studentDTO.getId());
studentVO.setName(studentDTO.getName());
studentVO.setSex(studentDTO.getSex());
studentVO.setAge(studentDTO.getAge());
if ("上海".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("沪");
studentVO.setPostCode("200000");
}
if ("北京".equals(studentDTO.getProvince())) {
studentVO.setAbbreviation("京");
studentVO.setPostCode("100000");
}
return studentVO;
}
可以看到代码量和可阅读性是提升很多的
Spock
Spock 核心标签
given : 可选标签,前面不能有其他代码块,不能重复使用when : when标签必须和 then标签一起出现then : 与 when一起使用expect :期望的行为,when-then的精简版cleanup : 清理资源相关的where : 在方法的最后面出现,不能重复
简单例子
def "HashMap accepts null key"() {
given:
def map = new HashMap()
when:
map.put(null, "elem")
then:
notThrown(NullPointerException)
}
given:
def file = new File("/some/path")
file.createNewFile()
cleanup:
file.delete()
with 表示期望值
def "offered PC matches preferred configuration"() {
when:
def pc = shop.buyPc()
then:
with(pc) {
vendor == "Sunny"
clockRate >= 2333
ram >= 406
os == "Linux"
}
}
spock基本方法
def setup() {}
def cleanup() {}
def setupSpec() {}
def cleanupSpec() {}
注意无论多么简单的测试,至少要有一个 expect: 块 或 when-then 块(别漏了在测试代码前加个 expect: 标签), 否则 Spock 会报 “No Test Found” 的错误
测试案例
1.引入依赖
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.3-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.6</version>
</dependency>
2. 不同用例测试
多 if else 测试
public static Map<String, String> getBirAgeSex(String certificateNo) {
String birthday = "";
String age = "";
String sex = "";
int year = Calendar.getInstance().get(Calendar.YEAR);
char[] number = certificateNo.toCharArray();
boolean flag = true;
if (number.length == 15) {
for (int x = 0; x < number.length; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
} else if (number.length == 18) {
for (int x = 0; x < number.length - 1; x++) {
if (!flag) return new HashMap<>();
flag = Character.isDigit(number[x]);
}
}
if (flag && certificateNo.length() == 15) {
birthday = "19" + certificateNo.substring(6, 8) + "-"
+ certificateNo.substring(8, 10) + "-"
+ certificateNo.substring(10, 12);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
certificateNo.length())) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
} else if (flag && certificateNo.length() == 18) {
birthday = certificateNo.substring(6, 10) + "-"
+ certificateNo.substring(10, 12) + "-"
+ certificateNo.substring(12, 14);
sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
age = (year - Integer.parseInt(certificateNo.substring(6, 10))) + "";
}
Map<String, String> map = new HashMap<>();
map.put("birthday", birthday);
map.put("age", age);
map.put("sex", sex);
return map;
}
class IDNumberUtilsTest extends Specification {
@Unroll
def "身份证号:#idNo 的生日,性别,年龄是:#result"() {
expect: "when + then 组合"
IDNumberUtils.getBirAgeSex(idNo) == result
where: "表格方式测试不同的分支逻辑"
idNo || result
"310168199809187333" || ["birthday": "1998-09-18", "sex": "男", "age": "22"]
"320168200212084268" || ["birthday": "2002-12-08", "sex": "女", "age": "18"]
"330168199301214267" || ["birthday": "1993-01-21", "sex": "女", "age": "27"]
"411281870628201" || ["birthday": "1987-06-28", "sex": "男", "age": "33"]
"427281730307862" || ["birthday": "1973-03-07", "sex": "女", "age": "47"]
"479281691111377" || ["birthday": "1969-11-11", "sex": "男", "age": "51"]
}
}
void方法测试
public void setOrderAmountByExchange(UserVO userVO){
if(null == userVO.getUserOrders() || userVO.getUserOrders().size() <= 0){
return ;
}
for(OrderVO orderVO : userVO.getUserOrders()){
BigDecimal amount = orderVO.getAmount();
// 获取汇率(调用汇率接口)
BigDecimal exchange = moneyDAO.getExchangeByCountry(userVO.getCountry());
amount = amount.multiply(exchange); // 根据汇率计算金额
orderVO.setAmount(amount);
}
}
class UserServiceTest extends Specification {
def userService = new UserService()
def moneyDAO = Mock(MoneyDAO)
void setup() {
userService.userDao = userDao
userService.moneyDAO = moneyDAO
}
def "测试void方法"() {
given: "设置请求参数"
def userVO = new UserVO(name:"James", country: "美国")
userVO.userOrders = [new OrderVO(orderNum: "1", amount: 10000), new OrderVO(orderNum: "2", amount: 1000)]
when: "调用设置订单金额的方法"
userService.setOrderAmountByExchange(userVO)
then: "验证调用获取最新汇率接口的行为是否符合预期: 一共调用2次, 第一次输出的汇率是0.1413, 第二次是0.1421"
2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421
and: "验证根据汇率计算后的金额结果是否正确"
with(userVO){
userOrders[0].amount == 1413
userOrders[1].amount == 142.1
}
}
}
异常测试
public void validateStudent(StudentVO student) throws BusinessException {
if(student == null){
throw new BusinessException("10001", "student is null");
}
if(StringUtils.isBlank(student.getName())){
throw new BusinessException("10002", "student name is null");
}
if(student.getAge() == null){
throw new BusinessException("10003", "student age is null");
}
if(StringUtils.isBlank(student.getTelephone())){
throw new BusinessException("10004", "student telephone is null");
}
if(StringUtils.isBlank(student.getSex())){
throw new BusinessException("10005", "student sex is null");
}
}
@Unroll
def "validate student info: #expectedMessage"() {
when: "校验"
tester.validateStudent(student)
then: "验证"
def exception = thrown(expectedException)
exception.code == expectedCode
exception.message == expectedMessage
where: "测试数据"
student || expectedException | expectedCode | expectedMessage
getStudent(10001) || BusinessException | "10001" | "student is null"
getStudent(10002) || BusinessException | "10002" | "student name is null"
getStudent(10003) || BusinessException | "10003" | "student age is null"
getStudent(10004) || BusinessException | "10004" | "student telephone is null"
getStudent(10005) || BusinessException | "10005" | "student sex is null"
}
def getStudent(code) {
def student = new StudentVO()
def condition1 = {
student.name = "张三"
}
def condition2 = {
student.age = 20
}
def condition3 = {
student.telephone = "12345678901"
}
def condition4 = {
student.sex = "男"
}
switch (code) {
case 10001:
student = null
break
case 10002:
student = new StudentVO()
break
case 10003:
condition1()
break
case 10004:
condition1()
condition2()
break
case 10005:
condition1()
condition2()
condition3()
break
}
return student
}
项目实战
1.idea快捷生成测试代码
2. 测试聚合根中的业务方法
public void review(FinancePurchaseOrderBillReviewObj reviewObj) {
financeExceptionObjs.stream().filter(s -> Objects.equals(s.getReviewResultEnum(), ReviewResultEnum.PENDING)).findFirst().ifPresent(s -> {
throw new BizException("存在异常审批未处理");
});
ReviewTypeEnum reviewType = reviewObj.getReviewType();
if (Objects.equals(reviewType, ReviewTypeEnum.FOLLOW)) {
this.billStatusEnum = BillStatusEnum.FOLLOW_ORDER_FAIL;
this.approved = LocalDateTime.now();
}
if (Objects.equals(reviewType, ReviewTypeEnum.SUPPLIER)) {
this.billStatusEnum = BillStatusEnum.FOLLOW_ORDER_PASS;
}
if (Objects.equals(reviewType, ReviewTypeEnum.DIRECTOR)) {
this.billStatusEnum = BillStatusEnum.SUPPLIER_FAIL;
this.ciderTime = LocalDateTime.now();
}
financePurchaseOrderBillReviewObjs.add(reviewObj);
this.followerId = reviewObj.getUid();
}
@Unroll
def "Review reviewType:#reviewType"() {
expect:
def billAgg = new BillAgg(id:1,financeExceptionObjs:financeExceptionObjs)
def obj = new FinancePurchaseOrderBillReviewObj(reason: "hahah",reviewType:reviewType, uid:uid)
when:
billAgg.review(obj)
then:
with(billAgg){
id == 1
billStatusEnum == billStatusEnumResult
followerId == followerIdResult
}
where: "测试数据"
reviewType| uid || billStatusEnumResult | financeExceptionObjs | followerIdResult
ReviewTypeEnum.FOLLOW| 1 || BillStatusEnum.FOLLOW_ORDER_FAIL | createFinanceExceptionObjs(false)| 1
ReviewTypeEnum.SUPPLIER| 2 || BillStatusEnum.FOLLOW_ORDER_PASS | createFinanceExceptionObjs(false)| 2
ReviewTypeEnum.DIRECTOR| 3 || BillStatusEnum.SUPPLIER_FAIL | createFinanceExceptionObjs(false)| 3
}
def createFinanceExceptionObjs(def create) {
if (create) {
return [new FinanceExceptionObj(id:1, reason:"测试不通过",price:20.2)]
}
return [new FinanceExceptionObj()]
}
def "Review exception"() {
given: "数据生成"
def financeExceptionObjs = [new FinanceExceptionObj(id:1, reviewResultEnum:ReviewResultEnum.PENDING)]
def billAgg = new BillAgg(id:1,financeExceptionObjs:financeExceptionObjs)
def obj = new FinancePurchaseOrderBillReviewObj(reason: "hahah",reviewType:ReviewTypeEnum.FOLLOW, uid:1, status: ReviewResultEnum.PENDING)
when: "方法调用"
billAgg.review(obj)
then: "结果验证"
def exception = thrown(BizException)
exception.getMessage() == "存在异常审批未处理"
}
3. 查看单元测试覆盖率
有绿色点代表已覆盖到,粉色的点代表未覆盖到.
实际我上面的测试用例都是覆盖到了,这里只是为了演示,所以图片是这样的
有数据库相关操作的mock测试
public ActionEnum reviewBill(BillReviewDTO billReviewDTO) {
BillAgg billAgg = financeRepository.getBillAgg(billReviewDTO.getId());
FinancePurchaseOrderBillReviewObj reviewObj = billConverter.toFinancePurchaseOrderBillReviewObj(billReviewDTO);
billAgg.review(reviewObj);
financeRepository.updateBillAgg(billAgg);
if (billReviewDTO.fail()) {
BillApprovalFailEvent event = new BillApprovalFailEvent(billReviewDTO.getId());
domainEventBus.publishDomainEvent(event);
}
return ActionEnum.SUCCESS;
}
class BillApplicationServiceTest extends Specification {
def financeRepository = Mock(FinanceRepository)
def service = new BillApplicationService()
def billConverter = new BillConverter()
def domainEventBus = Mock(DomainEventBus)
void setup() {
service.financeRepository = financeRepository
service.billConverter = billConverter
service.domainEventBus = domainEventBus
}
@Unroll
def "reviewBill status:#status reviewType:#reviewType"() {
given: "设置请求参数"
def billAgg = new BillAgg(id: 1l, purchaseOrderId: 10023l, purchaseOrderCount: 2)
def pushEvent = false
and: "mock掉接口返回的聚合根"
financeRepository.getBillAgg(_) >> billAgg
financeRepository.updateBillAgg(billAgg) >> {}
domainEventBus.publishDomainEvent(_) >> { pushEvent = true}
when: "调用账单审核"
def response = service.reviewBill(reviewDTO)
then: "验证结果正确性"
with(billAgg) {
id == 1L
billStatusEnum == billStatusEnumResult
pushEvent == pushEventResult
}
where: "测试数据"
reviewDTO || billStatusEnumResult | pushEventResult
getBillReviewDTO(0, 0) || BillStatusEnum.FOLLOW_ORDER_FAIL | true
getBillReviewDTO(1, 0) || BillStatusEnum.FOLLOW_ORDER_FAIL | true
}
def getBillReviewDTO(status, reviewType) {
def reviewDTO = new BillReviewDTO(id: 1l, uid: 2l, status:status, reviewType:reviewType)
return reviewDTO
}
}
参考资料
Spock单元测试框架介绍以及在美团优选的实践
Spock如何解决传统单元测试开发中的痛点 - 老K的Java博客
写有价值的单元测试
Spock官方文档
|