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

1,011 阅读12分钟

简介:本文更偏向实践而非方法论,所提及的SpringBoot单元测试写法亦并非官方解,仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格,只要能实现单元测试的效果,就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得,帮助像笔者这样的新人快速成长。

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

引言:

本文更偏向实践而非方法论,所提及的SpringBoot单元测试写法亦并非官方解,仅仅是笔者自身觉得比较方便、效率较高的一种写法。每个团队甚至团队内的每位开发可能都有自己的写法习惯和风格,只要能实现单元测试的效果,就没必要纠结于写法的简单抑或复杂。这里也欢迎各位大佬们发表看法或分享自己的单测心得,帮助像笔者这样的新人快速成长。

一 为什么要写单元测试?

测试是Devops上极重要的一环,但大多数开发的眼光都停留在集成测试这一环——只要能联调成功,那么我这次准备上线的特性一定是没问题的。

老实承认,我曾经是这样的可能现在也还是这样。作为非科班出身的笔者,研究生毕业后就立即进入了同在杭州的xx厂,先后参与了内部Devops平台建设和xx云Paas项目开荒,在这两个项目中,开发 > 测试是很正常的场景,甚至部分测试也是原开发友情客串的:由于缺少专业的测试人员,开发往往需要兼顾集成测试甚至是线上测试的活儿。为了提高效率,我将一部分常用的测试用例维护在了内部的自动化测试平台上。即便如此,我仍能清晰地感觉到,测试所能覆盖的场景屈指可数,以至于每次自信地上线大特性后,都会因一些奇怪的问题而定位到大半夜。幸亏后面遇到了一位资深大佬,在code review时,他直接点出我不写单元测试的坏习惯,并用自身惨痛的线上教训反复强调单测的重要性。

当然上述只是我的亲身经历,勉强作为日常闲聊的谈资。如果想要深入理解单元测试的重要性,推荐Google上搜索the importance of unit test关键字,可以感受下不同国家、不同领域的程序员对单元测试的不同理解,想必能有更大的收获。

二 为什么推荐链路思想?

深入接触单元测试,开发难免会遇到以下场景:

  1. 应该如何设计测试用例?
  2. 应该如何编写测试用例?
  3. 测试用例的质量该如何判定?

刚开始学习写单元测试,我也曾参考并尝试过网上五花八门的写法。这些写法可能用到了不同的单测框架,也可能侧重了不同的代码环节(例如特定的某个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

在已知待单测业务链路的前提下,写法可以简要归纳为以下几步:

  1. 初步设计单测用例框架。包括setup、teststep、teardown三步,setup负责处理一些全局必要的单测前置逻辑(例如Mock数据插入和环境准备),teststep承载单测用例的主体(要求以Assert类近似的断言语句为结尾),teardown负责处理一些全局必要的收尾逻辑(例如Mock数据删除和环境释放)
  2. 声明并初始化用例所涉及的所有链路环节。在已知链路流程的前提下,所有环节都可以依据是否为Mock点方法大致分为两类(参考上文中"用户买猪"图的灰、白点)。
  • 非Mock点方法:对于链路中非入口的环节(通常将controller作为入口,其他方法即为非入口),需要标注@Spy以声明该对象在单测链路中为监听状态,即需要正常走完流程。此处根据方法内是否引用Mock点方法进一步分成两类。

  • 该方法内引用了其他Mock点方法,需要在@Spy的基础上额外标注@InjectMocks,声明该对象在单测链路中需要被注入其他Mock对象。

  • 该方法内未引用其他Mock点方法,无需进行其他操作。

  • Mock点方法:标注@Mock以声明该对象在单测链路中需要被Mock,可以通过org.mockito.Mockito类内的一系列static方法手动注入Mock值(ep. when(A()).thenReturn(B))。

  1. 编写单测用例主体。在teststep中从controller层发起方法调用,最终通过Assert断言结果判断用例的成功与否。除了普通的返回值校验场景外,Junit也支持用@Test(expected = xxException.class)来声明该用例期望发生的异常类型。最后还是建议写完单测后能够以注释的形式说明该单测所支持的场景和预期结果的大致说明,方便以后自己和其他接手的同学能够快速了解这个单测用例的相关信息。

这里仍以"用户买猪"的场景为例,依照链路思想,当服务端收到用户购买猪肉的请求时,我们可以构造出如下分支场景:

  1. controller层存在可能出口,即weight == null。据此生成测试用例A,命名为testBuyPorkIfWeightIsNull,实际入参中weight==null,期望接口抛出异常;
  2. 按链路进入到PigServiceImpl中,存在可能出口,即hasStore() == false。据此生成测试用例B,命名为testBuyPorkIfStorageIsShortage,实际入参中weight必需大于库存值(如代码中setup预设库存为10,虚拟用户请求了20),期望接口抛出异常;
  3. 按链路继续执行,发现正常出口。据此生成测试用例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);
    }
}

原文链接

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