今日快讯:那些年,我们写过的无效单元测试

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


【资料图】

前言

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

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

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

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

1. 单元测试简介

1.1. 单元测试概念

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

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

1.2. 单元测试案例

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

1.2.1. 服务代码案例

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

@Servicepublic class UserService {    /** 定义依赖对象 */    /** 用户DAO */    @Autowired    private UserDAO userDAO;    /**     * 查询用户     *      * @param companyId 公司标识     * @param startIndex 开始序号     * @param pageSize 分页大小     * @return 用户分页数据     */    public PageDataVOqueryUser(Long companyId, Long startIndex, Integer pageSize) {        // 查询用户数据        // 查询用户数据: 总共数量        Long totalSize = userDAO.countByCompany(companyId);        // 查询接口数据: 数据列表        ListdataList = 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;        PageDataVOpageData = userService.queryUser(companyId, startIndex, pageSize);        log.info("testQueryUser: pageData={}", JSON.toJSONString(pageData));    }}

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

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

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/";        PageDataVOpageData = 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");        ListdataList = JSON.parseArray(text, UserVO.class);        Mockito.doReturn(dataList).when(userDAO).queryByCompany(companyId, startIndex, pageSize);        // 调用测试方法        PageDataVOpageData = 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);    }}

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

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

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(隔离的)

不一定支持

支持

通过上面表格的对比,可以得出以下结论:

集成测试基本上不一定满足所有单元测试原则; 单元测试基本上一定都满足所有单元测试原则。

所以,根据这些单元测试原则,可以看出集成测试具有很大的不确定性,不能也不可能完全代替单元测试。另外,集成测试始终是集成测试,即便用于代替单元测试也还是集成测试,比如:利用H2内存数据库测试DAO方法。

3. 无效单元测试

要想识别无效单元测试,就必须站在对方的角度思考——如何在保障单元测试覆盖率的前提下,能够更少地编写单元测试代码。那么,就必须从单元测试编写流程入手,看哪一阶段哪一方法可以偷工减料。

3.1. 单元测试覆盖率

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

代码覆盖(Code Coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率。

常用的单元测试覆盖率指标有:

1、行覆盖(Line Coverage):

用于度量被测代码中每一行执行语句是否都被测试到了。

2、分支覆盖(Branch Coverage):

用于度量被测代码中每一个代码分支是否都被测试到了。

3、条件覆盖(Condition Coverage):

用于度量被测代码的条件中每一个子表达式(true和false)是否都被测试到了。

4、路径覆盖(Path Coverage):

用于度量被测代码中的每一个代码分支组合是否都被测试到了。

除此之外,还有方法覆盖(Method Coverage)、类覆盖(Class Coverage)等单元测试覆盖率指标。

下面,用一个简单方法来分析各个单元测试覆盖率指标:

public static byte combine(boolean b0, boolean b1) {    byte b = 0;    if (b0) {        b |= 0b01;    }    if (b1) {        b |= 0b10;    }    return b;}

覆盖指标

测试用例

覆盖率

备注信息

行覆盖(Line Coverage)

combine(true, true)

100%

每一行执行语句都被执行到

分支覆盖(Branch Coverage)

combine(false, false) combine(true, true)

100%

每一个代码分支都被执行到

条件覆盖(Condition Coverage)

combine(false, true) combine(true, false)

100%

每一个条件子表达式都被执行到

路径覆盖(Path Coverage)

combine(false, false) combine(false, true) combine(true, false) combine(true, true)

100%

每一个代码分支组合都被执行到

单元测试覆盖率,只能代表被测代码的类、方法、执行语句、代码分支、条件子表达式等是否被执行,但是并不能代表这些代码是否被正确地执行并返回了正确的结果。所以,只看单元测试覆盖率,而不看单元测试有效性,是没有任何意义的。

3.2. 单元测试编写流程

首先,介绍一下作者总结的单元测试编写流程:

3.2.1. 定义对象阶段

定义对象阶段主要包括:定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)。

3.2.2. 模拟方法阶段

模拟方法阶段主要包括:模拟依赖对象(参数、返回值和异常)、模拟依赖方法。

3.2.3. 调用方法阶段

调用方法阶段主要包括:模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值和异常)。

3.2.4. 验证方法阶段

验证方法阶段主要包括:验证依赖方法、验证数据对象(参数)、验证依赖对象 。

3.3. 是否可以偷工减料

针对单元测试编写流程的阶段和方法,在不影响单元测试覆盖率的情况,我们是否可以进行一些偷工减料。

测试阶段

测试方法

可否偷减

主要原因

1.定义对象阶段

①定义测试对象

不可以

不定义测试对象,根本无法进行测试

②定义依赖对象(类成员)

不可以

不定义依赖对象(类成员),测试时会抛出空指针或无法进入期望分支

③注入依赖对象(类成员)

不可以

不注入依赖对象(类成员),测试会抛出空指针异常或无法进入期望分支

2.模拟方法阶段

②模拟依赖对象(参数、返回值和异常)

不可以

不模拟依赖对象(参数、返回值和异常),无法进入期望分支

④模拟依赖方法

不可以

不模拟模拟依赖方法,无法进入期望分支

3.调用方法阶段

②模拟依赖对象(参数)

不可以

不模拟依赖对象(参数),无法进入期望分支

⑤调用测试方法

不可以

不执行调用测试方法,根本无法进行测试

⑦验证数据对象(返回值和异常)

可以

不验证验证数据对象(返回值和异常),对单元测试覆盖率无影响

4.验证方法阶段

⑥验证依赖方法

可以

不验证依赖方法,对单元测试覆盖率无影响

⑦验证数据对象(参数)

可以

不验证数据对象(参数),对单元测试覆盖率无影响

⑧验证依赖对象

可以

不验证验证依赖对象,对单元测试覆盖率无影响

点击查看原文,获取更多福利!

https://developer.aliyun.com/article/1150453?utm_content=g_1000367898

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

关键词: 单元测试 测试用例 集成测试

推荐DIY文章
主机存在磨损或划痕风险 PICO4便携包宣布召回
穿越湖海!特斯拉Cybertruck电动皮卡可以当“船”用
vivoXFold+折叠旗舰开售 配备蔡司全焦段旗舰四摄
飞凡R7正式上市 全系标配换电架构
中兴Axon30S开售 拥有黑色蓝色两款配色
荣耀MagicBookV14 2022正式开售 搭载TOF传感器
it