测试左移,浅谈如何编写可反复执行的单元测试用例

背景

当下现状

  • 当下大多数公司、开发者对于测试工作依然是严重依赖测试团队,从而导致开发团队对单元测试编写更多是用于功能初次开发场景下,去针对性测试一次接口,看一看代码能否跑通,甚至不写单元测试直接转测。

  • 单元测试写的很随意,写不写全看开发自身约束情况,甚至见过先把服务启动然后通过 PostmanSwagger 进行代码测试;

  • 都是直接在方法内写明入参,并且基本不存在 断言 assert

  • 由于 强依赖数据库已有数据 ,换一个环境就跑不了了,或者由于数据变化就执行失败了;

  • 往往 不可重复执行 ,因为每次执行完后都会改变数据库的数据,典型如注册功能。

  • 不会跟随方法的修改而维护测试用例,导致一段时间后那些单元测试代码都变成 不可用的垃圾

让我们看看大家所熟悉的单元测试写法:

验证方式就是: 执行完成后自己去数据库看眼数据是否写入成功,下次执行再换个mobilePhone。而我是一个绝不相信,纯粹靠人的肉眼去保障质量是一个可以长期安全高效运转下去的方案,人参与的越多,就越容易出现意外。
在这里插入图片描述

期望目标

期望针对应用可以有一个 全面的可重复执行的具备纠错能力的有价值的 的质量保障体系。

当下困境

​ 人人都知道应该写单元测试,但是为何就是没法写出好的单元测试用例?甚至最后不写单元测试用例,那一定是遇到问题了。我基于个人经验简单写写自身所理解的问题。

  1. 单元测试执行后事务提交了,数据被修改了。导致无法重复执行;
  2. 单元测试执行就是依赖原有数据的,所以换了一个测试环境数据库,可能就玩不转了例如userId在其它环境不存在,并且业务库的 测试数据太难找,前置流程又臭又长,单元测试方法不好写;
  3. 写断言只验证一些关键信息也没用,因为非关键字段也有可能出现BUG;
  4. 依赖第三方接口,随便造数据人家会给报错,所以难以编写单元测试。
  5. 由于单元测试方法不可重复执行,所以当二次修改方法原有逻辑时,曾经的单元测试方法可能对你没有任何帮助,甚至由于 上一任 的离职,你也不了解这个方法的背景和细节,如果要去写单元测试 太费时了。然后自己改动的可能又只是一小段逻辑,所以让测试去测吧。一次又一次的小修改最后让曾经的单元测试方法彻底成为垃圾。
  6. 对单元测试缺乏有效的管理工具,写和不写全看开发人员,不写也不会怎么样,所以就会有部分方法大家主观认为没有BUG,所以不写。而恰恰是这些方法总会偶尔出问题;
  7. 没时间写单元测试,一堆活干不完,所以不写了。

找到问题,那我们就来依次解决问题。

解决问题

问题1:事务提交

​ 这个问题可就太简单了,我们只需要做到事务回滚即可。springframework 包下提供了一个注解 @Transactional ,

@Transactional

​ 在 Spring 体系中 @Transactional 主要被使用场景为 声明式事务 管理,同时也支持根据特定异常指定 事务回滚。详见:@Transactional详解(作用、失效场景与解决方法)

​ 但是大家都忽略了在 Junit 下去执行单元测试时,可以将此注解写在测试类顶部,可以标记所有测试方法事务都 默认回滚

代码示例

/**
 * @author Zhibo
 * @version 2.0
 * @date 2024-05-06
 * @Description: 所有单元测试类继承此类
 * 默认 单元测试设置事务回滚数据不提交
 * 如需设置为事务提交,请在指定的类 或 方法上 新增 @Rollback(value = false) 覆盖默认属性
 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {Application.class})
@Transactional
@Rollback(value = true)
public class BaseApplicationTest {

}

所有单元测试类通过继承此类,即可实现事务不提交,直接回滚的需求,不污染测试数据,让测试用例可以反复执行。

此注解无法回滚代码中手动开启、提交的事务。

问题2:对数据库数据强依赖

​ 如果我们全都使用 Mock 进行纯粹的脱离DB的单元测试,工作量太大,效率太低,并且也会遗漏掉数据库查询/写入这一块代码的测试。所以我们必须要去对数据库进行真实的查询、写入操作。

​ 但是如果直接使用数据库的数据作为基础数据进行测试,那么很多时候都会因为 数据发生了变化 导致本次测试结果不符合预期,然后就需要排查问题浪费大量时间。甚至因为公司存在多个测试环境,每个测试环境的历史数据并不一致,导致你的单元测试 水土不服,无法通用。

​ 所以我们有两个选择:

  • 不使用业务库:可选用 H2 这一类的内存型数据库每次启动自行初始化数据,但是需要维护表结构以及常量表、配置表等基础数据。
  • 使用业务库,但自行初始化数据:需要保证初始化的数据唯一索引列不被已有数据占用,且需要保证数据会被回滚。博主选择的这个方式

@Sql

@Sql 注解可以执行SQL脚本,也可以执行SQL语句。它既可以加上类上面,也可以加在方法上面。 默认情况下,方法上的@Sql注解会覆盖类上的@Sql注解,但可以通过@SqlMergeMode注解来修改此默认行为。搭配 @Transactional 一起使用就实现了测试数据的初始化,并且不用担心数据被提交导致下一次脚本执行失败。

代码示例

@Slf4j
@FixMethodOrder(MethodSorters.NAME_ASCENDING )
public class CourierServiceImplTest extends BaseApplicationTest {
    @Test
    @Sql({"/data/init/user/user_init.sql"})
    public void test1(){
        ...
        assert true;
    }
    
    // 多个SQL脚本用 逗号隔开
    @Test
    @Sql({"/data/init/user/user_init.sql","/data/init/role/role_init.sql"})
    public void test2(){
        ...
        assert true;
    }
}

脚本路径默认为当前项目 resources 包下。
在这里插入图片描述

SQL脚本示例

用一个负数当主键ID,以保证数据库中一定不存在此主键,避免数据插入失败。 用其它值也可以 只要保证数据库中不存在即可。

使用SQL脚本初始化数据最大的优势就是可以忽略前置流程,直接制造业务所需的数据,对复杂业务非常友好,且不论任何环境下都能支持
在这里插入图片描述

问题3:断言assert的使用

assert 我看到很多单元测试没有 assert 或者只去校验 主键ID、手机号之类的核心字段,其它return的信息都忽略了,因为return的数据字段多,且类似 修改时间、创建时间等字段难以校验。但是这样我认为是不可靠的。我们应该做到尽力进行全字段的校验,实现对响应结果的完全预测。

​ 通过 @SQL 注解注入的SQL脚本中 所有字段都是我可以绝对预知的,于是我可以基于SQL脚本,定义一个预期的返回对象,然后将两个对象 序列化转JSON 并进行 equals 比较,从而实现全字段校验。针对update方法,我只需要替换对象中 被修改的值 然后再进行 equals 比较,来实现全字段校验。

代码示例

DemoTest
@Slf4j
@FixMethodOrder(MethodSorters.NAME_ASCENDING )
pubilc class DemoTest extends BaseApplicationTest {
    @Autowired
    private UserServiceImpl userService;

	/**
     * {@link UicUserService#updateMobilePhone(BaseUserDTO, String, String, boolean)}
     * {@link UicUserService#updateMobilePhone(BaseUserDTO, String)}
     * 修改用户手机号 —— 正例
     * 验证密码修改用户手机号
     */
    @Test
    @Sql({"/data/init/user/user_init.sql"})
    public void updateMobilePhone(){
        BaseUserDTO baseUserDTO = new BaseUserDTO();
        baseUserDTO.setId(UserConstantTest.USER_ID);
        baseUserDTO.setUpdateEmp("updateMobilePhone");
        ResultDTO<BaseUserDTO> resultDTO = userService.updateMobilePhone(baseUserDTO,"18600000001",UserConstantTest.USER_PASSWORD,false);

        // 组装返回对象  UserResultTest.newBaseUserDTO() 返回一个根据sql脚本去创建的一个包含所有字段的对象
        BaseUserDTO checkDTO = UserResultTest.newBaseUserDTO();
        checkDTO.setMobilePhone("18600000001");
        checkDTO.setUpdateEmp("updateMobilePhone");
        checkDTO.setUpdateTime(resultDTO.getData().getUpdateTime());
        assert checkResultSuccess(resultDTO,checkDTO) && checkDTO.getUpdateTime().getTime() > JUNIT_BEGIN_TIME
                :   "01:    修改手机号失败";

        assert "18600000001".equals(userService.getUserInfoById(UserConstantTest.USER_ID).getMobilePhone())
                :   "02:    修改手机号失败";

        assert null == userService.getUserByAccount(UserConstantTest.USER_MOBILE)
                :   "03:    旧手机号索引删除失败";
    }
}
BaseApplicationTest
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {UicServiceApplication.class})
@Transactional
@Rollback(value = true)
public class UicServiceApplicationTest {
    protected static final Long JUNIT_BEGIN_TIME = System.currentTimeMillis();
    static {
        log.info("----------------------------- Uic Junit Test Begin time :{}--------------------------------",JUNIT_BEGIN_TIME);
    }

    @Test
    public void contextLoads() {
    }

    public static Boolean checkResultSuccess(ResultDTO resultDTO){
        boolean bol = Constants.SUCCESS.equals(resultDTO.getCode());
        if (!bol) warnLog(resultDTO);
        return bol;
    }

    public static Boolean checkResultSuccess(ResultDTO resultDTO,Object data){

        boolean bol = checkResultSuccess(resultDTO) && JSON.toJSONString(data).equals(JSON.toJSONString(resultDTO.getData()));
        if (!bol) warnLog(resultDTO,data);
        return bol;
    }

    public static Boolean checkObjEquals(Object obj,Object obj2){
        boolean bol = JSON.toJSONString(obj).equals(JSON.toJSONString(obj2));
        if (!bol) warnLog(obj,obj2);
        return bol;
    }

    public static Boolean checkResultError(ResultDTO resultDTO, String msg){
        boolean bol = Constants.FAIL.equals(resultDTO.getCode()) && msg.equals(resultDTO.getMsg());
        if (!bol) warnLog(resultDTO,msg);
        return bol;
    }

    public static void warnLog(Object... objs){
        log.warn("-----测试失败----- 接口实际返回结果:{}",JSON.toJSONString(objs[0]));
        if (objs.length>1)
            log.warn("-----测试失败----- 本轮测试期望返回:{}",JSON.toJSONString(objs[1]));

    }
}

通过JSON进行 equals 比较,可以保障本次测试的绝对可信度,当然也带来了一定的工作量,特别是当 return结果是一个List的时候, 去做校验 不光需要包含的元素一样,还要顺序都保持绝对的一致。

问题4:依赖第三方接口

​ 不论是内部跨系统调用、还是外部供应商/服务商的接口调用。都不是能够让我们随意去初始化测试数据的,并且有可能因为外部接口自身问题导致本次测试结果失败当单元测试用例写的足够多,全量跑一次耗时半个小时以上很正常,所以我们应该尽力屏蔽这些非自身的问题带来的测试困境与意外失败。

Mock

@InjectMocks

​ @InjectMocks 注解会根据测试类中的 @Mock 字段,自动创建被测试类的实例,并将模拟对象注入到相应的字段中。它首先尝试使用非默认构造函数,如果没有合适的构造函数,则会使用 setter 方法或直接赋值。

​ 一般 @InjectMocks 会搭配 @Mock 和 @SpyBean一起进行使用。

@Mock

​ Mockito中使用最广泛的注释是@Mock。我们可以使用@Mock创建和注入模拟实例。然后对需要 mock的方法进行返回值模拟,不去真实调用该方法。

@SpyBean

​ SpyBean注解是Spring Boot特有的,用于与Spring的依赖注入进行集成测试。因为 @InjectMocks 会导致改类下所有由Spring 注入的对象都失效,当你在测试一个方法,其中只有某一个Service的方法你需要mock,其它Service方法你依然想要走真实调用,就需要将他们注入了。

但是这是一个十分繁琐的事情,因为往往有些复杂代码前前后后调用了太多的Service,导致这里被迫注入多个实例。

代码示例

Test 类代码示例

/**
 * @author zhibo
 * @version 1.0.0
 * @Title: FifCodeVerifyServiceMockTest
 * @Package
 * @Description: {@link FifCodeVerifyService} Test
 */
public class FifCodeVerifyServiceMockTest extends BaseApplicationTest{
    // FifCodeVerifyService 中通过Spring注入了两个对象 BaseUserInfoService 、 FifUserDataServiceFacade
    @Autowired
    @InjectMocks
    private FifCodeVerifyService fifCodeVerifyService;

    // 本次测试已让想要真实的查询数据库,因此该对象用 @SpyBean 修饰
    @SpyBean
    private BaseUserInfoService baseUserInfoService;

    // 本轮测试不希望去调用 FifUserDataServiceFacade 的第三方接口,因此使用 @Mock 方法修饰
    @Mock
    private FifUserDataServiceFacade fifUserDataServiceFacade;

    // 在@Test标注的测试方法之前运行
    @Before
    public void setUp() throws Exception {
        // 初始化测试用例类中由Mockito的注解标注的所有模拟对象
        MockitoAnnotations.initMocks(this);
    }

    /**
     * FIF 老用户校验
     */
    @Test
    public void verify() {
        // 设置模拟对象的返回预期值,any()代表任意入参的意思
        when(fifUserDataServiceFacade.findUserBy(any())).thenReturn(getFifUserBaseInfoVO(1));

        UserLoginDTO userLoginDTO = FifUserCommon.getFIFUserLoginDTO(FifUserCommon.USER_MOBILE);
        userLoginDTO.setPasswd(FifUserCommon.USER_CODE);
        assert fifCodeVerifyService.verify(userLoginDTO);
    }
    
    public static FifUserBaseInfoVO getFifUserBaseInfoVO(int state){
        FifUserBaseInfoVO fifUserBaseInfoVO = new FifUserBaseInfoVO();
        fifUserBaseInfoVO.setCreated_at( new Date());
        fifUserBaseInfoVO.setState(state);
        fifUserBaseInfoVO.setOrgId(OrgIdEnum.FIF.getOrgId());
        fifUserBaseInfoVO.setUniquecode(FifUserCommon.USER_CODE);
        fifUserBaseInfoVO.setMobile(FifUserCommon.USER_MOBILE);
        return fifUserBaseInfoVO;
    }
}

被测试的 Service类代码示例

@Service
public class FifCodeVerifyService implements VerifyService {
    @Autowired
    private BaseUserInfoService baseUserInfoService;

    @Autowired
    private FifUserDataServiceFacade fifUserDataServiceFacade;

    @Override
    public boolean verify(UserBaseDTO userVo) {
        UserInfo userInfo = baseUserInfoService.getUserInfo(userVo.getMobile(),userVo.getOrgId());
        if (userInfo == null){
            return true;
        }
        return fifVerify(userVo,userInfo);
    }
    
    public boolean fifVerify(UserBaseDTO userVo, UserInfo userInfo){
        FifUserBaseInfoDTO fifUserBaseInfoDTO = new FifUserBaseInfoDTO();
        fifUserBaseInfoDTO.setMobile(MobileUtils.getMbile(userVo.getMobile()));
        fifUserBaseInfoDTO.setOrgId(userVo.getOrgId());
        fifUserBaseInfoDTO.setUniquecode(userVo.getPasswd());
        fifUserBaseInfoDTO.setUserId(userInfo.getId());
        FifUserBaseInfoVO fifUserBaseInfoVO = fifUserDataServiceFacade.findUserBy(fifUserBaseInfoDTO);
        if (fifUserBaseInfoVO == null || fifUserBaseInfoVO.getUniquecode() == null){
            throw new UserException(ResponsCodeType.UserResponsCodeTypeEnum.USER_PASSWORD_DISAGREE);
        }
        if (fifUserBaseInfoVO.getState() == -1){
            throw new UserException(ResponsCodeType.UserResponsCodeTypeEnum.FIF_CODE_INVALID);
        }
        return true;
    }
}
@MockBean ‘推荐’

@MockBean 是 Spring Boot Test提供的注解,用于在 Spring Boot 测试中创建一个模拟的 Bean 实例,并注入到测试类中的依赖项中。使用 Mock 可以控制被 Mock 对象的行为:自定义返回值、抛出指定异常等,模拟各种可能的情况,提高测试的覆盖率。需要注意的是:使用了 @MockBean,会创建完全模拟的对象,它完全替代了被模拟的 Bean,并且所有方法的调用都被模拟。对于未指定行为的方法,返回值如果是基本类型则返回对应基本类型的默认值,如果是引用类型则返回 null

由于 @MockBean 直接修改了容器中的Bean,所以当你一次批量运行多个测试类,而这些测试类中注入了不完全相同的 MockBean 对象,会导致容器重启,从而导致测试用例运行缓慢。@MockBean会改变spring boot application context beans,导致使用了@MockBean的测试类之间的需要不同application context,从而导致spring boot application context重启。 详见:@MockBean 危害

解决办法就是尽量将需要mock的Bean 都写入 BaseApplicationTest 中,由于所有测试类都继承 BaseApplicationTest ,所以所有测试类都拥有完全一致的 Mock对象,因此 spring boot application context 也就不需要重启了。

代码示例

BaseApplicationTest 代码示例

/**
 * @author Zhibo
 * @version 2.0
 * @date 2024-05-06
 * @Description: 所有单元测试类继承此类
 * 默认 单元测试设置事务回滚数据不提交
 * 如需设置为事务提交,请在指定的类 或 方法上 新增 @Rollback(value = false) 覆盖默认属性
 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {Application.class})
@Transactional
@Rollback(value = true)
public class BaseApplicationTest {
    // 所有测试方法不真实推送kafka消息
	@MockBean
    protected KafkaProducer kafkaProducer;
    
    // mock掉短信发送接口
    @MockBean
    protected TemplateSenderFacade templateSenderFacade;
    
    ......
}

测试类 代码示例

@Slf4j
@FixMethodOrder(MethodSorters.NAME_ASCENDING )
public class XxxServiceImplTest extends BaseApplicationTest {
    /**
     * {@link XxxFacadeImpl#openXxxx(BaseUserDTO)}
     *  xxxxx —— 正例
     */
    @Test
    public void openXxxx(){
        // 由于BaseApplicationTest 类中对 TemplateSenderFacade 进行mock, 如果此处不写mock返回 会直接返回null,导致流程异常,因此此处模拟返回短信发送成功响应
        when(templateSenderFacade.sendMsgSingle(any())).thenReturn(SingleResultTest.getMockSingleResult());


        ResultDTO<BaseUserDTO> resultDTO = xxxFacade.openXxx(getOpenXxxDTO());

        assert checkResultSuccess(resultDTO)
                :   "01     信息返回结果不符合预期";
        
        BaseUserDTO baseUserDTO = resultDTO.getData();
        log.info("openXxx User :{}",JSON.toJSONString(baseUserDTO));
        assert baseUserDTO.getMobilePhone().equals(UserConstantTest.USER_MOBILE) &&
                baseUserDTO.getRegTime().getTime() > JUNIT_BEGIN_TIME
                :   "02     信息返回结果不符合预期";
    }
}

问题5:不可重复执行

​ 基于以上的事务回滚方法,然后合理利用 @sql 初始化测试数据 并通过assert 进行结果校验,也就能解决不可重复执行的苦恼了

问题6:缺乏管理工具

​ 通过 Jacoco 的引入可以监控单元测试覆盖率,通过制定覆盖率标准,并规定每次需求转测前、需求上线前 都需要完成单元测试全量执行,并输出测试报告。自上而下的流程化、规范化来保障本次事项的持续推进,而不是雷声大雨点小。

问题7:时间问题

​ 好的单元测试代码是比较耗时的,测试代码的编写行数甚至超过业务代码的行数,但是这件事情本身是一件 功在当代利在千秋 的事情。完全可以通过给予开发人员更长的开发时间来让他们愿意接受这一份新增的工作量,随着单元测试的覆盖、转测质量必然大幅提升,我们可以随之压缩测试时间、测试人员数量,完成一个正向的闭环。

​ 做一件对的事情需要花费时间,重来就不是拒绝的理由,我们要做的是判断这个时间花费的值不值,要想让下面的兄弟好好做这件事,那就必须给他们足够的时间。

异常校验

​ 博主不推荐使用 @Tset 注解中的 expected 属性进行异常类型的校验,当下大多数公司为了方便开发,都会通过自定义异常来向前端返回错误信息,往往一个方法内会抛出多个相同的异常,只是msg 或 errorCode 不一样。 通过 expected 属性可以校验异常类型,但是无法更进一步的对异常进行校验,这就有可能存在抛出了错误的异常描述。

ExpectedException

​ org.junit.rules 包下的 ExpectedException 是一个更加全能的异常校验类。强烈推荐

代码示例

 	/** 测试方法异常校验规则 */
    @Rule
    public ExpectedException thrown = ExpectedException.none();

	/**
     * {@link UicUserServiceImpl#updateUserInfo(BaseUserDTO)}
     * 修改用户信息 —— 反例
     * 根据用户ID查询信息为null
     */
    @Test
    public void updateUserInfo_error_lock(){
        // 指定本次测试方法期望抛出的异常类型
        thrown.expect(UicRuntimeException.class);
        // 指定本次测试方法期望抛出的errorMsg
        thrown.expectMessage(UICEnum.USER_NOT_EXIST.getMsg());
        
        BaseUserDTO baseUserDTO = new BaseUserDTO();
        baseUserDTO.setId(UserConstantTest.USER_ID);
        uicUserService.updateUserInfo(baseUserDTO);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zhibo_lv

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值