如何写好一个单元测试

597 阅读24分钟

背景

  • 看到历史代码却不敢重构?在前人的代码屎山上越堆越高,最终系统可维护性变差,每次上线总是颤颤巍巍。

  • 每次改动代码都要让测试人员额外回归老逻辑?提测结果被打回?一切没有单测覆盖的重构都是裸奔。

1. 认识单测

1.1 什么是单元测试?

一个工厂生产电视机的例子。

工厂首先会将各种电子元器件按照图纸组装在一起构成各个功能电路板,比如供电板、音视频解码板、射频接收板等,然后再将这些电路板组装起来构成一个完整的电视机。

如果一切顺利,接通电源后,你就可以开始观看电视节目了。但是很不幸,大多数情况下组装完成的电视机根本无法开机,这时你就需要把电视机拆开,然后逐个模块排查问题。

假设你发现是供电板的供电电压不足,那你就要继续逐级排查组成供电板的各个电子元器件,最终你可能发现罪魁祸首是一个电容的故障。这时,为了定位到这个问题,你已经花费了大量的时间和精力。

那在后续的生产中,如何才能避免类似的问题呢?

你可能立即就会想到,为什么不在组装前,就先测试每个要用到的电子元器件呢?这样你就可以先排除有问题的元器件,最大程度地防止组装完成后逐级排查问题的事情发生。

实践也证明,这的确是一个行之有效的好办法。

如果把电视机的生产、测试和软件的开发、测试进行类比,你可以发现:

  • 电子元器件就像是软件中的单元,通常是函数或者类,对单个元器件的测试就像是软件测试中的单元测试;

  • 组装完成的功能电路板就像是软件中的模块,对电路板的测试就像是软件中的集成测试;

  • 电视机全部组装完成就像是软件完成了预发布版本,电视机全部组装完成后的开机测试就像是软件中的系统测试。
    通过这个类比,相信你已经体会到了单元测试对于软件整体质量的重要性,那么单元测试到底是什么呢?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

单元测试通常由开发工程师完成,一般会伴随开发代码一起递交至代码库。单元测试属于最严格的软件测试手段,是最接近代码底层实现的验证手段,可以在软件开发的早期以最小的成本保证局部代码的质量。

另外,单元测试都是以自动化的方式执行,所以在大量回归测试的场景下更能带来高收益。

同时,单元测试的实施过程还可以帮助开发工程师改善代码的设计与实现,并能在单元测试代码里提供函数的使用示例,因为单元测试的具体表现形式就是对函数以各种不同输入参数组合进行调用,这些调用方法构成了函数的使用说明。

1.2为什么要写单元测试(目的)

单元测试是所有测试类型中最基础的,它的优点是运行速度快,可以尽早地发现问题。只有通过单元测试保证了每个组件的正确性,我们才拥有了构建系统的一块块稳定的基石。

1.2.1 回归测试&自测保障

在修改已有代码时候,我们不得不考虑增量代码是否会对原有逻辑带来冲击,以及修复bug之后是否引入的新的bug,那么良好的、覆盖全面的单测可以起到很好的保障作用。

写单测的时候,也是我们验证逻辑的过程,通过单测我们甚至可以发现一些没有覆盖到的场景来健壮我们的系统。

1.2.2 重构保障&提升代码质量

每个程序员应该都要有对优雅代码的追求,对于老旧日过时的代码都应该持续迭代优化,而完善的单测可以在重构的自测环节进行保障。这样每个程序员都可以放心的优化代码,而不是一味的堆积代码。

1.2.3 单元测试是对集成测试的有力补充

程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用下一节课中讲到的 mock 的方式,控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。

除此之外,对于一些复杂系统来说,集成测试也无法覆盖得很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备。

尽管单元测试无法完全替代集成测试,但如果我们能保证每个类、每个函数都能按照我们的预期来执行,底层 bug 少了,那组装起来的整个系统,出问题的概率也就相应减少了。

集成测试相对于单元测试来说,是比较重的,启动配置比较麻烦,运行时间比较长。是一种比较耗时,所以我们应该尽量让代码更合理的划分,将业务逻辑实现放在领域层。且要减少领域层的代码对其他层的依赖。

1.2.4 单元测试是 TDD 可落地执行的改进方案

测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。不过,要让程序员能彻底地接受和习惯这种开发模式还是挺难的,毕竟很多程序员连单元测试都懒得写,更何况在编写代码之前先写好测试用例了。

单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点。

1.2.5 写单元测试能帮你发现代码设计上的问题

代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。

1.3 单元测试相关的概念

1.3.1 被测系统

被测系统(System under test, SUT)表示正在被测试的系统, 目的是测试系统能否正确操作. 根据测试类型的不同, SUT 指代的内容也不同, 例如 SUT 可以是一个类甚至是一整个系统.

在单元测试中被测试的单元是代码。

1.3.2 测试依赖组件(DOC)

被测系统所依赖的组件, 例如UserService的单元测试时, UserService 会依赖 UserDao, 因此 UserDao 就是 DOC.

1.3.3 测试替身(Test Double)

一个实际的系统会依赖多个外部对象, 但是在进行单元测试时, 我们会用一些功能较为简单的并且其行为和实际对象类似的假对象来作为被测系统的依赖对象, 以此来降低单元测试的复杂性和可实现性. 在这里, 这些假对象就被称为 测试替身(Test Double).

测试替身比较常用的 2 种类型:

Test stub

为被测系统提供数据的假对象,我们举一个例子来展示什么是 Test stub.

假设我们的一个模块需要从 HTTP 接口中获取商品价格数据, 这个获取数据的接口被封装为 getPrice 方法. 在对这个模块进行测试时, 我们显然不太可能专门开一个 HTTP 服务器来提供此接口, 而是提供一个带有 getPrice 方法的假对象, 从这个假对象中获取数据. 在这个例子中, 提供数据的假对象就叫做 Test stub.

Mock object

用于模拟实际的对象, 并且能够校验对这个 Mock object 的方法调用是否符合预期.

实际上, Mock object 是 Test stub的一种, 但是 Mock object 有 Test stub没有的特性, Mock object 可以很灵活地配置所调用的方法所产生的行为, 并且它可以追踪方法调用, 例如一个 Mock Object 方法调用时传递了哪些参数, 方法调用了几次等.

  • 驱动代码(Driver):指调用被测函数的代码,在单元测试过程中,驱动模块通常包括调用被测函数前的数据准备、调用被测函数以及验证相关结果三个步骤。驱动代码的结构,通常由单元测试的框架决定。

  • 桩代码(Test stub):是用来代替真实代码的临时代码。比如,某个函数A的内部实现中调用了一个尚未实现的函数B,为了对函数A的逻辑进行测试,那么就需要模拟一个函数B,这个模拟的函数B的实现就是所谓的桩代码。

  • Mock代码:和桩代码非常类似,都是用来代替真实代码的临时代码,起到隔离和补齐的作用。

Mock代码和桩代码的本质区别是:测试期待结果的验证(Assert and Expectiation)。

  • 对于Mock代码来说,我们的关注点是Mock方法有没有被调用,以什么样的参数被调用,被调用的次数,以及多个Mock函数的先后调用顺序。所以,在使用Mock代码的测试中,对于结果的验证(也就是assert),通常出现在Mock函数中。

  • 对于桩代码来说,我们的关注点是利用Stub来控制被测函数的执行路径,不会去关注Stub是否被调用以及怎么样被调用。所以,你在使用Stub的测试中,对于结果的验证(也就是assert),通常出现在驱动代码中。

2. 单元测试什么时候写

很多人的做法是先把所有的功能代码都写完,然后,再针对写好的代码一点一点地补写测试。

在这种编写测试的做法中,单元测试扮演着非常不受人待见的角色。你的整个功能代码都写完了,再去写测试就成了一件为了应付差事不得不做的事情。更关键的一点是,你编写的这些代码可能是你几天的工作量,你已经很难记得在编写这堆代码时所有的细节了,这个时候补写的测试对提升代码质量的帮助已经不是很大了。

其次,补单测时容易顺着当前实现去写测试代码,而忽略实际需求的逻辑是什么,导致我们的单测是无效的。

所以,想要写好单元测试,最后补测试的做法总是很糟糕的,仅仅比不写测试好一点。你要想写好单元测试的话,最好能够将代码和测试一起写。直到完成功能代码开发。当功能代码开发完时,单测也差不多完成了。

测试驱动开发(TDD)提倡:是在具体实现代码之前写单元测试。

你或许会说,我在功能写完后立即就补测试了,这不就是代码和测试一起写的吗?其中的差异在于,把所有的功能写完的这个粒度实在是太大了。为一个大任务编写测试,是一件难度非常大的事,这也是很多人觉得测试难写的重要因素。要想做好单元测试,关键就是工作的粒度要小。

粒度小:可以很好的保护代码可测试性

你需要把一个要完成的需求拆分成很多颗粒度很小的任务。粒度要小到可以在很短时间内完成,比如,(TDD中建议半个小时就可以写完)。只有能够把任务分解成微操作,我们才能够认清有足够的心力思考其中的每个细节。千万不要高估自己对于任务把控的粒度,一定要把任务分解到非常小,这是能够写好代码,写好测试的前提条件,甚至可以说是最关键的因素

当我们把需求拆分成颗粒度很小的任务时,我们才开始进入到编码的状态。而从这里开始,我们进入到代码和测试一起写的状态。

3. 单元测试怎么写

对于一个具体的任务,我们首先要弄清楚的是,怎么样算是完成了。一个完整的需求我们需要知道其验收标准是什么。 具体到一个任务,虽然没有业务人员给我们提供验收标准,我们自己也要有一个验收标准,我们要能够去衡量怎么样才算是这个代码写合格了。

单元测试的对象是代码,以及代码的基本特征和产生错误的原因。

3.1 FIRST原则

3.1.1 Fast (快速)

单测的执行不能过于耗时,有的单测执行几分钟是不合理的,好的单测应该是快速的,应该再几秒甚至毫秒级完成。

3.1.2 Isolted (隔离的)

我们的每个单测应该都是隔离的,独立的,准备数据和运行数据都应该是隔离的。测试时不要依赖和修改外部数据或文件等其他共享资源,做到测试前后共享资源数据一致。

3.1.3 Repeatable (可重复执行)

单测是可以重复执行的,不能受到外界环境的影响。同一测试用例,即使是在不同的机器,不同的环境中运行多次,每次运行都会产生相同的结果。

避免隐式输入(Hidden imput),比如测试代码中不能依赖当前日期,随机数等,否则程序就会变得不可控从而变得不可重复执行。

3.1.4 Self-verifying (可自我检测)

单测需要通过断言进行结果验证,即当单测执行完毕之后,用来判断执行结果是否和预期一致,无需人工检查是否执行成功。

当然,除了对执行结果进行检查,也可以对执行过程进行校验,如方法调用次数等。下面在工作中经常见到的写法,这些都是无效的单测。

 // 直接打印结果
public void testAddUser4DbError() {
    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    ResultDTO addResult = userService.addUser(fakeAddUserRequest);
    // THEN
    System.out.println(addResult);
}

// 吞没异常失败case
public void testAddUser4DbError() {

    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    try {
        ResultDTO addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        Assert.assertTrue(addResult.isSuccess());
    } catch (Exception e) {
        System.out.println("执行失败");
    }
}

正解如下:

@Test
public void testAddUser4DbError() {
    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest); // THEN
    Assert.assertEquals(addResult.getMsg(), " ");
}
3.1.5 Timely & Through (及时&全面的 )

边开发边写单测,避免再上线后再弥补单测。

理想情况下每行代码都要被覆盖到,每一个逻辑分支都必须有一个测试用例。
不过想要100%的测试覆盖率是非常耗费精力的,甚至会和我们最初提高效率的初衷相悖。所以花合理的时间抓出大多数bug,要好过穷尽一生抓出所有bug。所以并不是所有的代码都要进行单元测试,通常只有底层模块或者核心模块的测试中才会采用单元测试。

3.2 单元测试的结构

我们用测试来保证代码的正确性,然而,测试的正确性如何保证呢?

唯一可行的方案就是,把测试写简单,简单到一目了然,不需要证明它的正确性。由此,我们可以知道,一个复杂的测试肯定不是一个好的测试。

简单的测试应该长什么样呢?一起来看一个例子,

@Test
public void should_test_demo() {
  // 准备
  FooRepository repository = mock(FooRepository.class);
  when(repository.save(any())).then(returnsFirstArg());
  FooService service = new FooService(repository);
  
  // 执行  
  FooItem item = service.addFoo(new FooParameter("foo"));
  
  // 断言  
  assertThat(item.getContent()).isEqualTo("foo");
  
  // 清理(可选)
  
}

分成了四段,分别是准备、执行、断言和清理,这也是一般测试都会具备的四个阶段。

  • 准备。 :这个阶段是为了测试所做的一些准备,比如启动外部依赖的服务,存储一些预置的数据。在例子里面就是设置所需组件的行为,然后将这些组件组装了起来。

  • 执行。 :触发被测目标的行为。通常来说,它就是一个测试点,在大多数情况下,执行应该就是一个函数调用。如果是测试外部系统,就是发出一个请求。在我们这段代码里,它就是调用了一个函数。

  • 断言。 :断言是我们的预期,它负责验证执行的结果是否正确。比如,被测系统是否返回了正确的应答。在这个例子,我们验证的是 FooItem 的内容是否是我们添加进去的内容。

  • 清理。 :清理是一个可能会有的部分。如果在测试中使到了外部资源,在这个部分要及时地释放掉,保证测试环境被还原到一个最初的状态,就像什么都没发生过一样。比如,我们在测试过程中向数据库插入了数据,执行之后,要删除测试过程中插入的数据。

单元测试粒度的把握

如果一个测试里有多个执行目标,可能是需要在一个测试里要测多个不同的函数。这就是一个坏味道了。为什么说这是一个坏味道呢?因为测试的根基是简单,一旦复杂了,我们就很难保证测试本身的正确性。如果你有多个目标怎么办?分成多个测试就好了。

如果测试本身简单到令人发指的程度,出于节省代码篇幅的角度,你可以考虑在一个测试里面写。比如测试字符串为空的函数,我要分别传入空对象和空字符串,每种情况执行和断言一行代码就写完了,那在一个测试里面写就可以了。

4. 单元测试的实践

4.1 一个基本的单元测试

一个简单的单测,**应该符合GIVEN, WHEN,THEN的测试流程。GIVEN是我们调用被测单元的请求数据和被测方法的所需的数据。WHEN指调用方法,THEN指的是验证结果。
**

@Test
public void testAddUser4DbError() {
    // GIVEN
    AddUserRequest fakeAddUserRequest = new AddUserRequest();
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    ResultDTO addResult = userService.addUser(fakeAddUserRequest);
    // THEN
    Assert.assertEquals(addResult.getMsg(), "添加用户失败");
}

4.2 测试数据准备

在单测中,我们可能需要事先准备测试数据,以满足跑一个程序单元的基本要求。我们可以在执行程序单元前使用数据库 orm框架去插入数据,但是更简便的方式是使用spring test jdbc框架提供的注解去完成测试数据的提前注入。

@Sql注解可以执行SQL脚本,也可以执行SQL语句。它既可以加上类上面,也可以加在方法上面。

比如下面这个单测,目的是验证注册已注册用户的业务逻辑。所以在单测上使用@Sql注解执行sql脚本,脚本内容就是插入一条用户记录。

@Sql(scripts = "classpath:/db/testAddUserExistUserError.sql")
@Test
public void testAddUserExistUserError() {
    // GIVEN
    AddUserRequest fakeAddUserRequest = new AddUserRequest();
    fakeAddUserRequest.setUserName("eva");
    // WHEN
    ResultDTO<Long> addResult = userService.insert(fakeAddUserRequest);
    // THEN
    Assert.assertEquals(addResult.getMsg(), "用户已注册");
}

testAddUserExistUserError.sql

insert into user(name, age, state) values ('yuanhuajian', 11, 'VALID')

4.3 测试数据隔离

按照First原则中的隔离原则来说,在跑单测前的准备测试数据,和运行中改变的数据都应该与正常的运行环境数据相隔离。那么对于db来说,有以下两种方案可以做到隔离。

4.3.1 h2 内存数据库

借助内存数据库来做到测试数据的隔离,跑完单测后就会释放内存,也不会对运行环境数据库造成影响。

  • 优点:快速,只占用内存,用完就释放
  • 缺点:不支持一些mysql语法,也需要额外写表结构的sql。
4.3.2 Spring Test Transaction

使用spring test相框架提供的@Transaction和@Rollback注解就可以实现测试数据的隔离。

@Transaction@Rollback 可以加在测试类上,这样每个单测在跑完后,对于提前准备的测试数据 和运行单测产生的数据都会被回滚掉。原理也很简单,@Transaction让每个单测都变成一个事务, @Rollback会在单测执行完成后进行事务回滚。这样就不会对运行环境数据库库造成影响

  • 优点:简单,方便,用注解即可实现测试数据隔离。

  • 缺点:由于事务的特性,无法验证脱离事务的代码块,比如运行在子线程中的代码逻辑。

4.4 Mock 外部依赖

在程序中,往往还会依赖一些外部的数据,比如程序单元运行需要依赖外部的接口。按照隔离原则,我们的单测不应该依赖和修改外部资源。所以对于依赖的接口来说我们使用Mockito来mock掉一些外部依赖。

Mockito是一个在Junit下使用的Mock框架,Mockito 测试框架可以来模拟那些依赖的类,这些被模拟的对象在测试中充当真实对象的虚拟对象或克隆对象,而且Mockito 同时也提供了方便的测试行为验证。这样就可以让我们更多地去关注当前测试类的逻辑,而不是它所依赖的对象。

还是以注册用户举例,我们注册用户的业务逻辑中可能还需要同时去调用优惠券服务发放优惠券,但是单测中不应该依赖外部系统,假设外部系统挂掉,那单测永远都是执行失败的。假设外部系统是有效的,那可能真的给用户发放了券,这都是我们需要避免的,所以我们需要mock掉对优惠券服务的依赖。

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Rollback
public class UserServiceTest {
    @MockBean
    public CouponRemoteService couponRemoteService;
    @Test
    public void testAddUserSuccess() {
        // mock
        Mockito.doReturn(Response.success(true)).when(couponRemoteService);
        // GIVEN
        AddUserRequest fakeAddUserRequest = new AddUserRequest();
        fakeAddUserRequest.setUserName("yuanhuajian");
        // WHEN
        ResultDTO<Long> addResult = userService.insert(fakeAddUserRequest);
        // THEN
        Assert.assertEquals(addResult.getMsg(), "用户注册成功");
    }
}

以上的例子中,CouponRemoteService是用于管理优惠券的spring bean。我们使用[@MockBean ]( )注解使CouponRemoteService变成一个mock bean,在单测中我们可以自己定义mock bean的返回值。这样当单测运行到发放优惠券的方法时,会返回mock的结果,而不是真正的发起调用。

4.5 dubbo interface mock

上述方法中在对spring bean 的mock比较有效,但是在代码逻辑中如果直接依赖了使用dubbo @Reference注入接口可能就失效了。简单来说是因为通过@Reference注入的实例并不受spring 容器管理,所以无法被mock。可能需要一些特殊手段替换掉由dubbo注入的实例达到mock效果。

5. 验证

在阿里巴巴Java开发手册中,有这么一段话:**单元测试应该是全自动执行的,并且非交互式的**。我们不能依靠人肉来验证程序的正确性。所以应该使用断言(Asset)来验证程序正确性。

还是以注册用户举例,如果注册用户成功,那我们的数据库里必然需要存在一条用户记录,用户状态必须是有效的,那验证程序就应该是这样。

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Rollback
public class UserServiceTest {
    @Autowired
    private UserMapper userMapper;
    @Test
    public void testAddUserSuccess() {
        // GIVEN
        AddUserRequest fakeAddUserRequest = new AddUserRequest();
        fakeAddUserRequest.setUserName("yuanhuajian");
        // WHEN
        ResultDTO<Long> addResult = userService.insert(fakeAddUserRequest);
        // THEN
        Assert.assertEquals("用户注册成功", addResult.getMsg());
        User user = userMapper.findByUserName("yuanhuajian");
        Assert.assertNotNull(user); //存在用户
        Assert.assertEquals( "VALID", user.getState()); //状态有效
} }

6. 覆盖率

在写完单测后,对于单测效果最直观的统计指标就是覆盖率,覆盖率表示了你的代码有多少地方是被单测运行到,有多少地方是没有单测被运行到的。

6.1 覆盖率的意义

  1. 分析未覆盖部分的代码,从而反推在前期测试设计是否充分,没有覆盖到的代码是否是测试设计的盲点,为什么没有考虑到?需求/设计不够清晰,测试设计的理解有误,工程方法应用后的造成的策略性放弃等等,之后进行补充测试用例设计。

  2. 检测出程序中的废代码,可以逆向反推在代码设计中思维混乱点,提醒设计/开发人员理清代码逻辑关系,提升代码质量。

  3. 代码覆盖率高不能说明代码质量高,但是反过来看,代码覆盖率低,代码质量不会高到哪里去,可以作为测试自我审视的重要工具之一。

现在Java中主流的覆盖率统计工具有Jacoco、Cobertura,以jacoco为例,集成到项目中并执行测试后会生成覆盖率报告,类似于下图这样。

image.png

  • 指令覆盖率(Instruction) 表示在所有的指令中,哪些被指令过以及哪些没有被执行。
  • 分支覆盖率(Branch) 会统计所有的分支数量,并同时zhi出哪些分支被执行,哪些分支没有被执行。

  • 红色背景:无覆盖,该行的所有指令均无执行。

  • 黄色背景:部分覆盖,该行部分指令被执行。

  • 绿色背景:全覆盖,该行所有指令被执行。

指令覆盖率高不代表单测就一定有效,同时也需要看分支覆盖率,因为分支覆盖率表示了有多少分支没有覆盖,没有覆盖的分支也是最有可能出现问题的分支。所以分支覆盖率的重要性会大过于指令覆盖率。通过覆盖率我们可以发现未被覆盖的代码块以及分支,但不能过度追求覆盖率,我们应该把写单测的人力放在验证业务功能上,对于没太大必要验证的代码应该写少量单测去覆盖或者在覆盖率统计中排除掉。