关于单元测试:基于链路思想的SpringBoot单元测试快速写法

39次阅读

共计 10418 个字符,预计需要花费 27 分钟才能阅读完成。

简介:本文更偏差实际而非方法论,所提及的 SpringBoot 单元测试写法亦并非官网解,仅仅是笔者本身感觉比拟不便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有本人的写法习惯和格调,只有能实现单元测试的成果,就没必要纠结于写法的简略抑或简单。这里也欢送各位大佬们发表认识或分享本人的单测心得,帮忙像笔者这样的新人疾速成长。

作者 | 桃符
起源 | 阿里技术公众号

引言:

本文更偏差实际而非方法论,所提及的 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 的环节涂灰,从而变成咱们在编写单元测试用例时所须要的“虚构用户买猪”图。

四 疾速写法实际案例

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 {
    @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));
    }
}

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 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();}
}

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,理论入参中 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
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);
    }
}

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0