编写健壮的单元测试

本文包括以下内容:

  • 如何解决数据依赖,编写健壮的单元测试
  • 如何处理异常分支和边界场景

前言

一次完整的开发,代码编写其实只占据了很小的一部分,而更多时间,则被花费在了调试上面。在开发过程中,我们经常需要自己编写一套单元测试用例,自测代码的正确性。

单元测试的意义

一套完整、有效、健壮的单元测试,往往能够帮助我们:

  • 检测代码正确性,暴露代码中存在的Bug
  • 快速定位Bug
  • 暴露边界与异常场景
  • 快速review代码
  • 提高代码质量,减少QA时间

编写单元测试常见的问题

我们为什么要编写单元测试,这里就不说了,在写单元测试的过程中,我们会经常碰到这样的问题:

  • 数据依赖
  • 忽略了异常分支与边界场景
  • 逻辑分支太多,无法覆盖所有的分支

解决方法

数据依赖

其实编写单元测试,基本避免不了数据依赖的场景,而数据依赖,基本来源于两个方面:

  1. 外部数据
  2. 数据库数据

而一个健壮的单元测试,原则上不应该有数据依赖,或者说,不依赖于现有的数据。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Test extends AbstractTransactionalJUnit4SpringContextTests {

@Mock
ProductService productService;

@InjectMocks
ActivityHttpExportServiceImpl activityHttpExportService;

/**
* Mock数据
*/
@BeforeMethod
public void setup() {
MockitoAnnotations.initMocks(this);
//setField...
when(productService.getProductInfo(any())).thenAnswer(invocationOnMock -> {
List<ProductInfo> productInfos = new ArrayList<>();
return productInfos;
});
}

/**
* 初始化数据
*/
private void initOrderActivity() {
OrderActivityDTO orderActivityDTO = new OrderActivityDTO();
orderActivityDTO.setName("测试");
//...
orderActivityDTOMapper.insert(orderActivityDTO);
}

/**
* 测试
*/
@Test
public void testGetActivityProductInfo_TC2() {
initOrderActivity();
ActivityProductResponse activityProductInfo = activityHttpExportService.getActivityProductInfo(activityId, 0,10);
Assert.assertNotNull(activityProductInfo);
// Assert ...
}

}

对外部数据的依赖

在有外部数据依赖的程序中,想要完整覆盖这部分测试逻辑,势必要运行调用外部应用接口获取返回值,但是如果真实进行接口访问,又与程序之间产生耦合,与设计思想不符。

而设想当外部依赖还未开发完成,亦或者外部依赖的程序有错误,获得了与预期不符的返回值,那么势必对本身代码逻辑产生影响,需要我们花费大量的时间进行排查,影响开发效率。

  • 如何解决依赖:

    • 外部数据Mock
    • 中间件Mock
  • 如何实现
    在上例中,当进行测试覆盖activityHttpExportService.getActivityProductInfo()方法的时候,产生了对外部服务 productService.getProductInfo()的依赖,采用Mockito进行Mock数据,当运行至 productService.getProductInfo() 方法的时候,并不会真是调用外部接口,而是将Mock的数据进行返回。然后程序会继续执行下去,检验本身程序的逻辑代码。

    1
    2
    3
    4
    when(productService.getProductInfo(any())).thenAnswer(invocationOnMock -> {
    List<ProductInfo> productInfos = new ArrayList<>();
    return productInfos;
    });

对数据库的依赖

如果数据涉及到对数据库的CRUD操作,那么应该真实进行CRUD操作么?还是直接用Mock返回操作结果?

其实我觉得是应该真实操作数据库的,因为对数据库的CRUD,也是当前单元的逻辑操作的一部分,同样也会出现Bug。但是如果对真实数据CRUD,又产生了对数据库数据进行破坏的可能。

  • 如何解决:

    • 初始化时插入数据,事务自动rollback
  • 如何实现
    上例中Test类继承自AbstractTransactionalJUnit4SpringContextTests类,我们首先来看下AbstractTransactionalJUnit4SpringContextTests

    1
    2
    3
    4
    5
    @TestExecutionListeners({TransactionalTestExecutionListener.class, SqlScriptsTestExecutionListener.class})
    @Transactional
    public abstract class AbstractTransactionalJUnit4SpringContextTests extends AbstractJUnit4SpringContextTests {
    // ...
    }

AbstractTransactionalJUnit4SpringContextTests继承自 AbstractJUnit4SpringContextTests,并且注册了TransactionalTestExecutionListener.classSqlScriptsTestExecutionListener.class两个监听器,并开启事务,默认执行完毕自动Rollback。

所以我们可以在单元测试开始前,向数据库中插入记录,如例中的initOrderActivity()方法,然后根据插入的数据去做CRUD操作,整个程序虽然依赖了数据库,但是并未依赖数据,并检验了对数据库的逻辑操作。

异常与边界场景

其实在编写测试代码的时候,我们应该让它先失败,断言其异常期望值,因为当覆盖正确的测试逻辑之后,异常逻辑往往会被忽略,获得到期望的异常值。

先检验异常逻辑的好处:

  • 异常 ≠ 失败,断言异常可以证明测试机制正常运行
  • 检测错误期望值,会让对整合逻辑的认知更加清晰
  • 暴露存在的界场景,补充完善代码逻辑

在开发初期,往往很少暴露出边界场景,但是在单元测试的过程中,对于异常的思考往往能触发对边界的思考。

  • 比如这样的问题

    购买商品时,购买数加已购买数不能大于总限制数

    1
    2
    3
    4
    5
    if(bug > 0 && bug + own <= LIMIT){   
    System.out.println("...");
    }else{
    System.out.println("失败!");
    }

当输入负数时,肯定是失败的,那么就会想何时会成功?边界场景有哪些呢?

* 0
* 正最大
* 负最大

那么输入int最大时会怎样?假设 LIMIT = 1000;own = 100;
2147483647 + 1000会越界变成负数,这种情况是不是就满足情况了呢?

其他

  • 对于暴露出来的bug,可以写一个单元测试来暴露这个bug,当review代码事也可以快速review该bug

  • 单元测试可以排除很大一部分bug,但是不一定能发现所有的bug,在写单元测试的时候,应当尽可能去覆盖所有的分支,测试用例的粒度不是越低越好,但是应当覆盖100%的逻辑

  • 单元测试是以模块为单位的,对于其他单元测试已经覆盖的重复的实现逻辑,可以直接Mock返回这部分的返回值