那些年,我们写过的无效单元测试

前言

那些年,为了学分,我们学会了 面向过程编程
那些年,为了就业,我们学会了 面向对象编程
那些年,为了生活,我们学会了 面向工资编程
那些年,为了升职加薪,我们学会了 面向领导编程
那些年,为了完成指标,我们学会了 面向指标编程
……
那些年,我们学会了 敷衍编程
那些年,我们 编程只是为了 敷衍

现在,领导要响应集团提高代码质量的号召,需要提升单元测试的代码覆盖率。当然,我们不能让领导失望,那就加班加点地补充单元测试用例,努力提高单元测试的代码覆盖率。至于单元测试用例的有效性,我们大抵是不用关心的,因为我们只是面向指标编程

我曾经阅读过一个Java服务项目,单元测试的代码覆盖率非常高,但是通篇没有一个依赖方法验证(Mockito.verify)、满纸仅存几个数据对象断言(Assert.assertNotNull)。我说,这些都是无效的单元测试用例,根本起不到测试代码BUG和回归验证代码的作用。后来,在一个月黑风高的夜里,一个新增的方法调用,引起了一场血雨腥风。

编写单元测试用例的目的,并不是为了追求单元测试代码覆盖率,而是为了利用单元测试验证回归代码——试图找出代码中潜藏着的BUG。所以,我们应该具备工匠精神、怀着一颗敬畏心,编写出有效的单元测试用例。在这篇文章里,作者通过日常的单元测试实践,系统地总结出一套避免编写无效单元测试用例的方法和原则。

1. 单元测试简介

1.1. 单元测试概念

在维基百科中是这样描述的:

在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。

1.2. 单元测试案例

首先,通过一个简单的服务代码案例,让我们认识一下集成测试和单元测试。

1.2.1. 服务代码案例

这里,以用户服务(UserService)的分页查询用户(queryUser)为例说明。

@Service
public class UserService {
    /** 定义依赖对象 */
    /** 用户DAO */
    @Autowired
    private UserDAO userDAO;

    /**
     * 查询用户
     * 
     * @param companyId 公司标识
     * @param startIndex 开始序号
     * @param pageSize 分页大小
     * @return 用户分页数据
     */
    public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
        // 查询用户数据
        // 查询用户数据: 总共数量
        Long totalSize = userDAO.countByCompany(companyId);
        // 查询接口数据: 数据列表
        List<UserVO> dataList = null;
        if (NumberHelper.isPositive(totalSize)) {
            dataList = userDAO.queryByCompany(companyId, startIndex, pageSize);
        }

        // 返回分页数据
        return new PageDataVO<>(totalSize, dataList);
    }
}

1.2.2. 集成测试用例

很多人认为,凡是用到JUnit测试框架的测试用例都是单元测试用例,于是就写出了下面的集成测试用例。

@Slf4j
@RunWith(PandoraBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ExampleApplication.class})
public class UserServiceTest {
    /** 用户服务 */
    @Autowired
    private UserService userService;

    /**
     * 测试: 查询用户
     */
    @Test
    public void testQueryUser() {
        Long companyId = 123L;
        Long startIndex = 90L;
        Integer pageSize = 10;
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        log.info("testQueryUser: pageData={}", JSON.toJSONString(pageData));
    }
}

集成测试用例主要有以下特点:

  1. 依赖外部环境和数据;
  2. 需要启动应用并初始化测试对象;
  3. 直接使用@Autowired注入测试对象;
  4. 有时候无法验证不确定的返回值,只能靠打印日志来人工核对。

1.2.3. 单元测试用例

采用JUnit+Mockito编写的单元测试用例如下:

@Slf4j
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
    /** 定义静态常量 */
    /** 资源路径 */
    private static final String RESOURCE_PATH = "testUserService/";

    /** 模拟依赖对象 */
    /** 用户DAO */
    @Mock
    private UserDAO userDAO;

    /** 定义测试对象 */
    /** 用户服务 */
    @InjectMocks
    private UserService userService;

    /**
     * 测试: 查询用户-无数据
     */
    @Test
    public void testQueryUserWithoutData() {
        // 模拟依赖方法
        // 模拟依赖方法: userDAO.countByCompany
        Long companyId = 123L;
        Long startIndex = 90L;
        Integer pageSize = 10;
        Mockito.doReturn(0L).when(userDAO).countByCompany(companyId);

        // 调用测试方法
        String path = RESOURCE_PATH + "testQueryUserWithoutData/";
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        String text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
        Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));

        // 验证依赖方法
        // 验证依赖方法: userDAO.countByCompany
        Mockito.verify(userDAO).countByCompany(companyId);

        // 验证依赖对象
        Mockito.verifyNoMoreInteractions(userDAO);
    }

    /**
     * 测试: 查询用户-有数据
     */
    @Test
    public void testQueryUserWithData() {
        // 模拟依赖方法
        String path = RESOURCE_PATH + "testQueryUserWithData/";
        // 模拟依赖方法: userDAO.countByCompany
        Long companyId = 123L;
        Mockito.doReturn(91L).when(userDAO).countByCompany(companyId);
        // 模拟依赖方法: userDAO.queryByCompany
        Long startIndex = 90L;
        Integer pageSize = 10;
        String text = ResourceHelper.getResourceAsString(getClass(), path + "dataList.json");
        List<UserVO> dataList = JSON.parseArray(text, UserVO.class);
        Mockito.doReturn(dataList).when(userDAO).queryByCompany(companyId, startIndex, pageSize);

        // 调用测试方法
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
        Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));

        // 验证依赖方法
        // 验证依赖方法: userDAO.countByCompany
        Mockito.verify(userDAO).countByCompany(companyId);
        // 验证依赖方法: userDAO.queryByCompany
        Mockito.verify(userDAO).queryByCompany(companyId, startIndex, pageSize);

        // 验证依赖对象
        Mockito.verifyNoMoreInteractions(userDAO);
    }
}

单元测试用例主要有以下特点:

  1. 不依赖外部环境和数据;
  2. 不需要启动应用和初始化对象;
  3. 需要用@Mock来初始化依赖对象,用@InjectMocks来初始化测试对象;
  4. 需要自己模拟依赖方法,指定什么参数返回什么值或异常;
  5. 因为测试方法返回值确定,可以直接用Assert相关方法进行断言;
  6. 可以验证依赖方法的调用次数和参数值,还可以验证依赖对象的方法调用是否验证完毕。

2.3. 单元测试原则

为什么集成测试不算单元测试呢?我们可以从单元测试原则上来判断。在业界,常见的单元测试原则有AIR原则和FIRST原则。

2.3.1. AIR原则

AIR原则内容如下:

  1. A-Automatic(自动的)单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。
  2. I-Independent(独立的)单元测试应该保持的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能对外部资源有所依赖。
  3. R-Repeatable(可重复的)单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放入持续集成中,每次有代码提交时单元测试都会被执行。

2.3.2. FIRST原则

FIRST原则内容如下:

  1. F-Fast(快速的)单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,大型项目的单元测试通常应该在几分钟内运行完毕。
  2. I-Independent(独立的)单元测试应该是可以独立运行的,单元测试用例互相之间无依赖,且对外部资源也无任何依赖。
  3. R-Repeatable(可重复的)单元测试应该可以稳定重复的运行,并且每次运行的结果都是稳定可靠的。
  4. S-SelfValidating(自我验证的)单元测试应该是用例自动进行验证的,不能依赖人工验证。
  5. T-Timely(及时的)单元测试必须及时进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量。

2.3.3. ASCII原则

阿里的夕华先生也提出了一条ASCII原则

  1. A-Automatic(自动的)单元测试应该是全自动执行的,并且非交互式的。
  2. S-SelfValidating(自我验证的)单元测试中必须使用断言方式来进行正确性验证,而不能根据输出进行人肉验证。
  3. C-Consistent(一致的)单元测试的参数和结果是确定且一致的。
  4. I-Independent(独立的)单元测试之间不能互相调用,也不能依赖执行的先后次序。
  5. I-Isolated(隔离的)单元测试需要是隔离的,不要依赖外部资源。

2.3.4. 对比集测和单测

根据上节中的单元测试原则,我们可以对比集成测试和单元测试的满足情况如下:

原则名称 原则项目 集成测试 单元测试
AIR原则 Automatic(自动的) 不一定支持 支持
Independent(独立的) 不一定支持 支持
Repeatable(可重复的) 不一定支持 支持
FIRST原则 Fast(快速的) 不一定支持 支持
Independent(独立的) 不一定支持 支持
Repeatable(可重复的) 不一定支持 支持
SelfValidating(自我验证的) 不一定支持 支持
Timely(及时的) - -
ASCII原则 Automatic(自动的) 不一定支持 支持
SelfValidating(自我验证的) 不一定支持 支持
Consistent(一致的) 不一定支持 支持
Independent(独立的) 不一定支持 支持
Isolated(隔离的) 不一定支持
  • 10
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值