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

Posted 阿里云云栖号

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了那些年,我们写过的无效单元测试相关的知识,希望对你有一定的参考价值。

前言

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

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

我曾经阅读过一个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(隔离的)不一定支持支持

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

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

所以,根据这些单元测试原则,可以看出集成测试具有很大的不确定性,不能也不可能完全代替单元测试。另外,集成测试始终是集成测试,即便用于代替单元测试也还是集成测试,比如:利用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.验证方法阶段⑥验证依赖方法可以不验证依赖方法,对单元测试覆盖率无影响
⑦验证数据对象(参数)可以不验证数据对象(参数),对单元测试覆盖率无影响
⑧验证依赖对象可以不验证验证依赖对象,对单元测试覆盖率无影响

3.4. 最终可以得出结论

通过上表格,可以得出结论,偷工减料主要集中在验证阶段

  1. 调用方法阶段
    1. 验证数据对象(返回值和异常)

  1. 验证方法阶段
    1. 验证依赖方法
    2. 验证数据对象(参数)
    3. 验证依赖对象

通过一些合并和拆分,后续将从以下三部分展开:

  1. 验证数据对象(包括属性、参数和返回值);
  2. 验证抛出异常;
  3. 验证依赖方法(包括依赖方法和依赖对象)。

4. 验证数据对象

在单元测试中,验证数据对象是为了验证是否传入了期望的参数值、返回了期望的返回值、设置了期望的属性值。

4.1. 数据对象来源方式

在单元测试中,需要验证的数据对象主要有以下几种来源。

4.1.1. 来源于被测方法的返回值

数据对象来源于调用被测方法的返回值,例如:

PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);

4.1.2. 来源于依赖方法的参数捕获

数据对象来源于验证依赖方法的参数捕获,例如:

ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
UserDO userCreate = userCreateCaptor.getValue();

4.1.3. 来源于被测对象的属性值

数据对象来源于获取被测对象的属性值,例如:

userService.loadRoleMap();
Map<Long, String> roleMap = Whitebox.getInternalState(userService, "roleMap");

4.1.4. 来源于请求参数的属性值

数据对象来源于获取请求参数的属性值,例如:

OrderContext orderContext = new OrderContext();
orderContext.setOrderId(12345L);
orderService.supplyProducts(orderContext);
List<ProductDO> productList = orderContext.getProductList();

当然,数据对象还有其它来源方式,这里就不再一一举例了。

4.2. 数据对象验证方式

在调用被测方法时,需要对返回值和异常进行验证;在验证方法调用时,也需要对捕获的参数值进行验证。

4.2.1. 验证数据对象空值

JUnit提供Assert.assertNull和Assert.assertNotNull方法来验证数据对象空值。

// 1. 验证数据对象为空
Assert.assertNull("用户标识必须为空", userId);

// 2. 验证数据对象非空
Assert.assertNotNull("用户标识不能为空", userId);

4.2.2. 验证数据对象布尔值

JUnit提供Assert.assertTrue和Assert.assertFalse方法来验证数据对象布尔值的真假。

// 1. 验证数据对象为真
Assert.assertTrue("返回值必须为真", NumberHelper.isPositive(1));

// 2. 验证数据对象为假
Assert.assertFalse("返回值必须为假", NumberHelper.isPositive(-1));

4.2.3. 验证数据对象引用

JUnit提供Assert.assertSame和Assert.assertNotSame方法来验证数据对象引用是否一致。

// 1. 验证数据对象一致
Assert.assertSame("用户必须一致", expectedUser, actualUser);

// 2. 验证数据对象不一致
Assert.assertNotSame("用户不能一致", expectedUser, actualUser);

4.2.4. 验证数据对象取值

JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法组,可以用来验证数据对象值是否相等。

// 1. 验证简单数据对象
Assert.assertNotEquals("用户名称不一致", "admin", userName);
Assert.assertEquals("账户金额不一致", 10000.0D, accountAmount, 1E-6D);

// 2. 验证简单集合对象
Assert.assertArrayEquals("用户标识列表不一致", new Long[] 1L, 2L, 3L, userIds);
Assert.assertEquals("用户标识列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);

// 3. 验证复杂数据对象
Assert.assertEquals("用户标识不一致", Long.valueOf(1L), user.getId());
Assert.assertEquals("用户名称不一致", "admin", user.getName());
...

// 4. 验证复杂集合对象
Assert.assertEquals("用户列表长度不一致", expectedUserList.size(), actualUserList.size());
UserDO[] expectedUsers = expectedUserList.toArray(new UserDO[0]);
UserDO[] actualUsers = actualUserList.toArray(new UserDO[0]);
for (int i = 0; i < actualUsers.length; i++)  
     Assert.assertEquals(String.format("用户 (%s) 标识不一致", i), expectedUsers[i].getId(), actualUsers[i].getId()); 
     Assert.assertEquals(String.format("用户 (%s) 名称不一致", i), expectedUsers[i].getName(), actualUsers[i].getName());
     ...
;

// 5. 通过序列化验证数据对象
String text = ResourceHelper.getResourceAsString(getClass(), "userList.json");
Assert.assertEquals("用户列表不一致", text, JSON.toJSONString(userList));;

// 6. 验证数据对象私有属性字段
Assert.assertEquals("基础包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));

当然,数据对象还有其它验证方法,这里就不再一一举例了。

4.3. 验证数据对象问题

这里,以分页查询公司用户为例,来说明验证数据对象时所存在的问题。

代码案例:

public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) 
    // 查询用户数据
    // 查询用户数据: 总共数量
    Long totalSize = userDAO.countByCompany(companyId);
    // 查询接口数据: 数据列表
    List<UserVO> dataList = null;
    if (NumberHelper.isPositive(totalSize)) 
        List<UserDO> userList = userDAO.queryByCompany(companyId, startIndex, pageSize);
        dataList = userList.stream().map(UserService::convertUser)
            .collect(Collectors.toList());
    

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

private static UserVO convertUser(UserDO userDO) 
    UserVO userVO = new UserVO();
    userVO.setId(userDO.getId());
    userVO.setName(userDO.getName());
    userVO.setDesc(userDO.getDesc());
    ...
    return userVO;

4.3.1. 不验证数据对象

反面案例:

很多人为了偷懒,对数据对象不进行任何验证。

// 调用测试方法
userService.queryUser(companyId, startIndex, pageSize);

存在问题:

无法验证数据对象是否正确,比如被测代码进行了以下修改:

// 返回分页数据
return null;

4.3.2. 验证数据对象非空

反面案例:

既然不验证数据对象有问题,那么我就简单地验证一下数据对象非空。

// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertNotNull("分页数据不为空", pageData);

存在问题:

无法验证数据对象是否正确,比如被测代码进行了以下修改:

// 返回分页数据
return new PageDataVO<>();

4.3.3. 验证数据对象部分属性

反面案例:

既然简单地验证数据对象非空不行,那么我就验证数据对象的部分属性。

// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals("数据总量不为空", totalSize, pageData.getTotalSize());

存在问题:

无法验证数据对象是否正确,比如被测代码进行了以下修改:

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

4.3.4. 验证数据对象全部属性

反面案例:

验证数据对象部分属性也不行,那我验证数据对象所有属性总行了吧。

// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId);
Assert.assertEquals("数据总量不为空", totalSize, pageData.getTotalSize());
Assert.assertEquals("数据列表不为空", dataList, pageData.getDataList());

存在问题:

上面的代码看起来很完美,验证了PageDataVO中两个属性值totalSize和dataList。但是,如果有一天在PageDataVO中添加了startIndex和pageSize,就无法验证这两个新属性是否赋值正确。代码如下:

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

备注:本方法仅适用于属性字段不可变的数据对象

4.3.5. 完美地验证数据对象

对于数据对象属性字段新增,有没有完美的验证方案?有的!答案就是利用JSON序列化,然后比较JSON文本内容。如果数据对象新增了属性字段,必然会提示JSON字符串不一致。

完美案例:

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

备注:本方法仅适用于属性字段可变的数据对象。

4.4. 模拟数据对象准则

由于没有模拟数据对象章节,这里在验证数据对象章节中插入了模拟数据对象准则。

4.4.1. 除触发条件分支外,模拟对象所有属性值不能为空

在上一节中,我们展示了如何完美地验证数据对象。但是,这种方法真正完美吗?答案是否定。

比如:我们把userDAO.queryByCompany方法返回的uesrList的所有UserDO对象的属性值name和desc赋值为空,再把convertUser方法的name和desc赋值做一下交换,上面的单元测试用例是无法验证出来的。

private static UserVO convertUser(UserDO userDO) 
    UserVO userVO = new UserVO();
    userVO.setId(userDO.getId());
    userVO.setName(userDO.getDesc());
    userVO.setDesc(userDO.getName());
    ...
    return userVO;

所以,在单元测试中,除触发条件分支外,模拟对象所有属性值不能为空。

4.4.2. 新增数据类属性字段时,必须模拟数据对象的属性值

在上面的案例中,如果UserDO和UserVO新增了属性字段age(用户年龄),且新增了赋值语句如下:

userVO.setAge(userDO.getAge());

如果还是用原有的数据对象执行单元测试,我们会发现单元测试用例执行通过。这是因为,由于属性字段age为空,赋值不赋值没有任何差别。所以,新增属性类属性字段是,必须模拟数据对象的属性值。

注意:如果用JSON字符串对比,且设置输出空字段,是可以触发单元测试用例执行失败的。

4.5. 验证数据对象准则

4.5.1. 必须验证所有数据对象

在单元测试中,必须验证所有数据对象:

  1. 来源于被测方法的返回值
  2. 来源于依赖方法的参数捕获
  3. 来源于被测对象的属性值
  4. 来源于请求参数的属性值。

具体案例可以参考《数据对象来源方式》章节。

4.5.2. 必须使用明确语义的断言

在使用断言验证数据对象时,必须使用确定语义的断言,不能使用不明确语义的断言。

正例:

Assert.assertTrue("返回值不为真", NumberHelper.isPositive(1));
Assert.assertEquals("用户不一致", user, userService.getUser(userId));

反例:

Assert.assertNotNull("用户不能为空", userService.getUser(userId));
Assert.assertNotEquals("用户不能一致", user, userService.getUser(userId));

谨防一些试图绕过本条准则的案例,试图用明确语义的断言去做不明确语义的判断。

Assert.assertTrue("用户不能为空", Objects.nonNull(userService.getUser(userId)));

4.5.3. 尽量采用整体验证方式

如果一个模型类,会根据业务需要新增字段。那么,针对这个模型类所对应的数据对象,尽量采用整体验证方式。

正例:

UserVO user = userService.getUser(userId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "user.json");
Assert.assertEquals("用户不一致", text, JSON.toJSONString(user));

反例:

UserVO user = userService.getUser(userId);
Assert.assertEquals("用户标识不一致", Long.valueOf(123L), user.getId());
Assert.assertEquals("用户名称不一致", "changyi", user.getName());
...

上面这种数据验证方式,如果模型类删除了属性字段,是可以验证出来的。但是,如果模型类添加了字段,是无法验证出来的。所以,如果采用了这种验证方式,在新增了模型类属性字段后,需要梳理并补全测试用例。否则,在使用单元测试用例回归代码时,它将会告诉你这里没有任何问题

5. 验证抛出异常

异常作为Java语言的重要特性,是Java语言健壮性的重要体现。捕获并验证抛出异常,也是测试用例的一种。所以,在单元测试中,也需要对抛出异常进行验证。

5.1. 抛出异常来源方式

5.1.1. 来源于属性字段的判断

判断属性字段是否非法,否则抛出异常。

private Map<String, MessageHandler> messageHandlerMap = ...;
public void handleMessage(Message message) 
    ...
    // 判断处理器映射非空
    if (CollectionUtils.isEmpty(messageHandlerMap)) 
        throw new ExampleException("消息处理器映射不能为空");
    
    ...

5.1.2. 来源于输入参数的判断

判断输入参数是否合法,否则抛出异常。

public void handleMessage(Message message) 
    ...
    // 判断获取处理器非空
    MessageHandler messageHandler = messageHandlerMap.get(message.getType());
    if (CollectionUtils.isEmpty(messageHandler)) 
        throw new ExampleException("获取消息处理器不能为空");
    
    ...

注意:这里采用的是Spring框架提供的Assert类,跟if-throw语句的效果一样。

5.1.3. 来源于返回值的判断

判断返回值是否合法,否则抛出异常。

public void handleMessage(Message message) 
    ...
    // 进行消息处理器处理
    boolean result = messageHandler.handleMessage(message);
    if (!reuslt) 
        throw new ExampleException("处理消息异常");
    
    ...

5.1.4. 来源于模拟方法的调用

调用模拟的依赖方法时,可能模拟的依赖方法会抛出异常。

public void handleMessage(Message message) 
    ...
    // 进行消息处理器处理
    boolean result = messageHandler.handleMessage(message); // 直接抛出异常
    ...

这里,可以进行异常捕获处理,或打印输出日志,或继续抛出异常。

5.1.5. 来源于静态方法的调用

有时候,静态方法调用也有可能抛出异常。

// 可能会抛出IOException
String response = HttpHelper.httpGet(url, parameterMap);

除此之外,还有别的抛出异常来源方式,这里不再累述。

5.2. 抛出异常验证方式

在单元测试中,通常存在四种验证抛出异常方法。

5.2.1. 通过try-catch语句验证抛出异常

Java单元测试用例中,最简单直接的异常捕获方式就是使用try-catch语句。

@Test
public void testCreateUserWithException() 
    // 模拟依赖方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 调用测试方法
    UserCreateVO userCreate = new UserCreateVO();
    try 
        userCreate.setName("changyi");
        userCreate.setDescription("Java Programmer");
        userService.createUser(userCreate);
     catch (ExampleException e) 
        Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, e.getCode());
        Assert.assertEquals("异常消息不一致", "用户已存在", e.getMessage());
    

    // 验证依赖方法
    Mockito.verify(userDAO).existName(userCreate.getName());

5.2.2. 通过@Test注解验证抛出异常

JUnit的@Test注解提供了一个expected属性,可以指定一个期望的异常类型,用来捕获并验证异常。

@Test(expected = ExampleException.class)
public void testCreateUserWithException() 
    // 模拟依赖方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 调用测试方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    userService.createUser(userCreate);

    // 验证依赖方法(不会执行)
    Mockito.verify(userDAO).existName(userCreate.getName());

注意:测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证异常编码、消息、原因等内容,也无法验证依赖方法及其参数。

5.2.3. 通过@Rule注解验证抛出异常

如果想要验证异常原因和消息,就需求采用@Rule注解定义ExpectedException对象,然后在测试方法的前面声明要捕获的异常类型、原因和消息。

@Rule
public ExpectedException exception = ExpectedException.none();
@Test
public void testCreateUserWithException1() 
    // 模拟依赖方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 调用测试方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    exception.expect(ExampleException.class);
    exception.expectMessage("用户已存在");
    userService.createUser(userCreate);

    // 验证依赖方法(不会执行)
    Mockito.verify(userDAO).existName(userCreate.getName());

注意:测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证依赖方法及其参数。由于ExpectedException的验证方法只支持验证异常类型、原因和消息,无法验证异常的自定义属性字段值。目前,JUnit官方建议使用Assert.assertThrows替换。

5.2.4. 通过Assert.assertThrows方法验证抛出异常

在最新版的JUnit中,提供了一个更为简洁的异常验证方式——Assert.assertThrows方法。

@Test
public void testCreateUserWithException() 
    // 模拟依赖方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 调用测试方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreate));
    Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
    Assert.assertEquals("异常消息不一致", "用户已存在", exception.getMessage());

    // 验证依赖方法
    Mockito.verify(userDAO).existName(userCreate.getName());

5.2.5. 四种抛出异常验证方式对比

根据不同的验证异常功能项,对四种抛出异常验证方式对比。结果如下:

对比内容try-catch语句@Test注解@Rule注解Assert.assertThrows方法
验证异常类型支持支持支持支持
验证异常消息支持不支持支持支持
验证异常原因支持不支持支持支持
验证自定义属性支持不支持不支持支持
验证依赖方法及其参数支持不支持不支持支持
单元测试代码优雅性不优雅优雅不优雅优雅
JUnit官方推荐使用不推荐推荐不推荐推荐

综上所述,采用Assert.assertThrows方法验证抛出异常是最佳的,也是JUnit官方推荐使用的。

5.3. 验证抛出异常问题

这里,以创建用户时抛出异常为例,来说明验证抛出异常时所存在的问题。

代码案例:

private UserDAO userDAO;
public void createUser(@Valid UserCreateVO userCreateVO) 
    try 
        UserDO userCreateDO = new UserDO();
        userCreateDO.setName(userCreateVO.getName());
        userCreateDO.setDesc(userCreateVO.getDesc());
        userDAO.create(userCreateDO);
     catch (RuntimeException e) 
        log.error("创建用户异常: userName=", userName, e)
        throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户异常", e);
    

5.3.1. 不验证抛出异常类型

反面案例:

在验证抛出异常时,很多人使用@Test注解的expected属性,并且指定取值为Exception.class,主要原因是:

  1. 单元测试用例的代码简洁,只有一行@Test注解;
  2. 不管抛出什么异常,都能保证单元测试用例通过。
@Test(expected = Exception.class)
public void testCreateUserWithException() 
    // 模拟依赖方法
    Throwable e = new RuntimeException();
    Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

    // 调用测试方法
    UserCreateVO userCreateVO = ...;
    userService.createUser(userCreate);

存在问题:

上面用例指定了通用异常类型,没有对抛出异常类型进行验证。所以,如果把ExampleException异常改为RuntimeException异常,该单元测试用例是无法验证出来的。

throw new RuntimeException("创建用户异常", e);

5.3.2. 不验证抛出异常属性

反面案例:

既然需要验证异常类型,简单地指定@Test注解的expected属性为ExampleException.class即可。

@Test(expected = ExampleException.class)
public void testCreateUserWithException() 
    // 模拟依赖方法
    Throwable e = new RuntimeException();
    Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

    // 调用测试方法
    UserCreateVO userCreateVO = ...;
    userService.createUser(userCreate);

存在问题:

上面用例只验证了异常类型,没有对抛出异常属性字段(异常消息、异常原因、错误编码等)进行验证。所以,如果把错误编码DATABASE_ERROR(数据库错误)改为PARAMETER_ERROR(参数错误),该单元测试用例是无法验证出来的。

throw new ExampleException(ErrorCode.PARAMETER_ERROR, "创建用户异常", e);

5.3.3. 只验证抛出异常部分属性

反面案例:

如果要验证异常属性,就必须用Assert.assertThrows方法捕获异常,并对异常的常用属性进行验证。但是,有些人为了偷懒,只验证抛出异常部分属性。

// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.DATABASE_ERROR, exception.getCode());

存在问题:

上面用例只验证了异常类型和错误编码,如果把错误消息"创建用户异常"改为"创建用户错误",该单元测试用例是无法验证出来的。

throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户错误", e);

5.3.4. 不验证抛出异常原因

反面案例:

先捕获抛出异常,再验证异常编码和异常消息,看起来很完美了。

// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());

存在问题:

通过代码可以看出,在抛出ExampleException异常时,最后一个参数e是我们模拟的userService.createUser方法抛出的RuntimeException异常。但是,我们没有对抛出异常原因进行验证。如果修改代码,把最后一个参数e去掉,上面的单元测试用例是无法验证出来的。

throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户异常");

5.3.5. 不验证相关方法调用

反面案例:

很多人认为,验证抛出异常就只验证抛出异常,验证依赖方法调用不是必须的。

// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());

存在问题:

如果不验证相关方法调用,如何能证明代码走过这个分支?比如:我们在创建用户之前,检查用户名称无效并抛出异常。

// 检查用户名称有效
String userName = userCreateVO.getName();
if (StringUtils.length(userName) < USER_NAME_LENGTH) 
    throw new ExampleException(ErrorCode.INVALID_USERNAME, "无效用户名称");

5.3.6. 完美地验证抛出异常

一个完美的异常验证,除对异常类型、异常属性、异常原因等进行验证外,还需对抛出异常前的依赖方法调用进行验证。

完美案例:

// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 调用测试方法
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserCreateVO userCreateVO = JSON.parseObject(text, UserCreateVO.class);
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());

// 验证依赖方法
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateDO.json");
Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));

5.4. 验证抛出异常准则

5.4.1. 必须验证所有抛出异常

在单元测试中,必须验证所有抛出异常:

  1. 来源于属性字段的判断
  2. 来源于输入参数的判断
  3. 来源于返回值的判断
  4. 来源于模拟方法的调用
  5. 来源于静态方法的调用

具体内容可以参考《抛出异常来源方式》章节。

5.4.2. 必须验证异常类型、异常属性、异常原因

在验证抛出异常时,必须验证异常类型、异常属性、异常原因等。

正例:

ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", "用户已存在", exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());

反例:

@Test(expected = ExampleException.class)
public void testCreateUserWithException() 
    ...
    userService.createUser(userCreateVO);

5.4.3. 验证抛出异常后,必须验证相关方法调用

那些年我写过的mysql命令

那些年,我们一起写过的“单例模式”

那些年,我们一起写过的“单例模式”

那些年我们写过的T-SQL(上)

那些年我们写过的文案

那些年我们写过的爬虫