单元测试——工程师 Style 的测试方法

什么是单元测试?

测试分类

Wikipedia 对单元测试的定义:

在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。

在实际测试中,一个单元可以小到一个方法,也可以大到包含多个类。从定义上讲,单元测试和集成测试是有严格的区分的,但是在实际开发中它们可能并没有那么严格的界限。如果专门追求单元测试必须测试最小的单元,反而容易造成多余的测试并且不易维护。换句更严谨一点的说法,我们要考虑测试的场景再去选择不同粒度的测试。

单元测试和集成测试即可以手工执行,也可以是程序自动执行。但现在一般提到单元测试,都是指自动执行的测试。所以我们下面提到的单元测试,没有特别注明,都是泛指自动执行的单元测试或集成测试。

单元测试入门

下面我们先看两个案例,感受一下单元测试到底是什么样子的。

例子 1:生命游戏单元测试

我们先看一个很简单的例子,实现一个康威生命游戏。如果不了解康威生命游戏的话,可以看 Wikipedia 的介绍。假设我们实现时定义这样的接口:

public interface Game {
    void init(int[][] shape) ;      // 初始化游戏 board 
    void tick();                    // 行进到在一个回合
    int[][] get();                  // 获取当前游戏 board
}

生命游戏有好几条规则,为了测试我们的实现是否正确,我们可以针对生命游戏的每个规则,写一个单元测试。下面测试的是复活的规则。

@Test
public void testRelive() {
    int[][] shape = {{0, 0, 1}, {0, 0, 0}, {1, 0, 1}};
    Game g = new GameImplSample(shape);
    g.tick();
    // 自己死亡,周围3个存活状态,复活
    assertEquals(1, g.get()[1][1]);
}

例子 2:订单退款集成测试

我们在看一个稍微复杂一些的例子,测试的是订单退款的过程。

@Test
public void test300_doRefundItem() {
    // 创建订单、支付,然后退款
    Order order = createOrder(OrderSource.XR_DOCTOR);
    order = fullPay(order, PayType.WECHAT_JS);
    OrderItem item = _doItemRefund(order, 1, false);

    // 检查退款中状态
    OrderWhole orderWholeRefunding = findOrderWhole(order.getOrderNo());
    isTrue(orderWholeRefunding.getRefundStatus().equals(
        OrderRefundStatus.PARTIAL_REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getStatus().equals(
        RefundStatus.REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getItemId().get().equals(
        item.getId()));

    // 构建退款的回调信息
    List<Payment> payments = findPayments(order.getId());
    List<Refund> refunds = findRefunds(order.getId());
    wxRefundNotify(payments.get(0), refunds.get(0), WxRefundStatus.SUCCESS);

    // 检查退款后状态
    OrderWhole orderWholeFinish = assertRefund(order, FULL_PAID, 
        PARTIAL_REFUND_OK, RefundStatus.SUCCESS, RefundMode.ITEM, false);
    isTrue(orderWholeFinish.getRefundFee() == item.getPaidPrice());
    isTrue(orderWholeFinish.getIncomes().stream()
        .filter(i -> i.getAmount() < 0).count() == 1);
}

单元测试执行

单元测试有很多种执行方式:
- 在 IDE 中执行
- 通过 mvn 或者 gradle 运行
- 在 CI 中执行

不论什么方式,单元测试都应该很容易就能运行,并给出一个测试结果。当然,单元测试运行速度得快,一般是在秒级的,太慢的话就不能及时获得反馈了。

为什么要写单元测试?

单元测试的好处

  • 确保代码满足需求或者设计规格。 使用单元测试来测试代码,可以通过构造数据和前置条件,确保测试覆盖到需要测试的逻辑。而手工测试或 UI 测试则无法做到,并且往往更复杂。
  • 快速定位并解决问题。 单元测试因为测试范围比较小,可以比较容易的定位到问题;而手工测试,常常需要耗费不少时间去定位问题。
  • 确保代码永远满足需求规格。 一旦需要对实现进行修改,单元测试可以确保代码的正确性,极大的降低各种修改和重构的风险。特别是避免那些在意想不到之处出现的 BUG。
  • 简化系统集成。 单元测试确保了系统或模块本身的正确性,集成时更不容易出错。
  • 提高代码质量和可维护性。 不可测试的代码,其本身的抽象性、模块性、可维护性是有些问题的。例如不符合单一职责、接口隔离等设计原则,或者依赖了全局变量。可测试的代码,往往其质量相对会高一些。
  • 提供文档和说明。 单元测试本身就是接口使用方法的很好的案例。

持续集成和持续交付

2010 年前后,大部分互联网公司的系统部署还是通过手工的方式进行的,往往要在半夜上线系统。但是之后持续集成、持续交付的理念不断推广,部署过程越来越灵活、顺畅。而单元测试则是持续集成和持续交付里重要的一环。

持续集成就是 Continuous Integration(CI),也就是指从开发上传代码、自动构建和测试、最后反馈结果的过程。

持续集成

更进一步,如果自动构建和测试后,会自动发布到测试环境或预发布环境,执行更多测试(集成测试、自动化 UI 测试等),甚至最后直接发布,那这一过程就是持续交付(Continuous Delivery,CD)。业内有不少公司,比如亚马逊、Esty,可以做到每天几十甚至成百上千次生产环境部署,就是因为有比较完善的持续交付环境。

CI 已经是互联网行业必备标准,CD 也在互联网行业有了越来越多的实践,但是如果没有单元测试这一环节,CI 和 CD 的过程是有缺陷的。

怎么写单元测试?

JUnit 简介

基本上每种语言和框架都有不错的单元测试框架和工具,例如 Java 的 JUnit、Scala 的 ScalaTest、Python 的 unittest、JavaScript 的 Jest 等。上面的例子都是基于 JUnit 的,我们下面就简单介绍下 JUnit。

  • JUnit 里面每个 @Test 注解的方法,就是一个测试。@Ignore 可以忽略一个测试。@Before、@BeforeClass、@After、@AfterClass 可以在测试执行前后插入一些通用的操作,比如初始化和资源释放等等。
  • 除了 assertEquals,JUnit 也支持不少其他的 assert 方法。例如 assertNull、assertArrayEquals​、assertThrows、assertTimeout​ 等。另外也可以用第三方的 assert 库比如 Spring 的 Assert 或者 AssertJ。
  • 除了可以测试普通的代码逻辑,JUnit 也可以进行异常测试和时间测试。异常测试是测试某段代码必须抛指定的异常,时间测试则是测试代码执行时间在一定范围内。
  • 也可以对测试进行分组。例如可以分成 contractTest 、mockTest 和 unitTest,通过参数指定执行某个分组的测试。

这里就不做过多介绍了,想了解更多 JUnit 的可以去看 极客学院的 JUnit 教程 等资料。其他的单元测试框架,基本功能都是大同小异。

使用测试 Double

狭义的单元测试,我们是只测试单元本身。即使我们写的是广义的单元测试,它依然可能依赖其他模块,比如其他类的方法、第三方服务调用或者数据库查询等等,造成我们无法很方便的测试被测系统或模块。这时我们就需要使用测试 Double 了。

如果细究的话,测试 Double 分成好多种,比如什么 Dummies、Fakes 等等。但我认为我们只要弄清两类就可以了,也就是 Stub 和 Mock。

Stub

Stub 指那些包含了预定义好的数据并且在测试时返回给调用者的对象。Stub 常被用于我们不希望返回真实数据或者造成其他副作用的场景。

我们契约测试生成的、可以通过 spring cloud stubrunner 运行的 Stub Jar 就是一个 Stub。我们可以让 Stub 返回预设好的假数据,然后在单元测试里就可以依赖这些数据,对代码进行测试。例如,我们可以让用户查询 Stub 根据参数里的用户 ID 返回认证用户和未认证用户,然后我们就可以测试调用方在这两种情况下的处理逻辑了。

当然,Stub 也可以不是远程服务,而是另外一个类。所以我们经常说要针对接口编程,因为这样我们就可以很容易的创建一个接口的 Stub 实现,从而替换具体的类。

public class StubNameService implement NameService {
    public String get(String userId) {
        return "Mock user name";
    }
}

public class UserServiceTest {
    // UserService 依赖 NameService,会调用其 get 方法
    @Inject
    private UserService userService;    

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        userService.setNameService(new StubNameService());
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
    }
}

不过这样要实现很多 Stub 也是很麻烦的,现在我们已经不需要自己创建 Stub 了,因为有了各种 Mock 工具。

Mock

Mocks 指那些可以记录它们的调用信息的对象,在测试断言中我们可以验证 Mocks 被进行了符合期望的调用。

Mock 和 Stub 的区别在于,Stub 只是提供一些数据,它并不进行验证,或者只是基于状态做一些验证;而 Mock 除了可以做 Stub 的事情,也可以基于调用行为进行验证。比如说,Mock 可以验证 Mock 接口被调用了不多不少正好两次,并且调用的参数是期望的数值。

Java 里最常用的 Mock 工具就是 Mockito 了。我们来看一个简单的例子,下面的 UserService 依赖 NameService。当我们测试 UserService 的时候,我们希望隔离 NameService,那么就可以创建一个 Mock 的 NameService 注入到 UserService 中(在 Spring 里只需要用 @Mock 和 @InjectMocks 两个注解就可以完成了)。

public class UserServiceTest {
    @InjectMocks
    private UserService userService;
    @Mock
    private NameService nameService;

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        Mockito.when(nameService.getUserName("SomeId")).thenReturn("Mock user name");
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
        Mockito.verify(nameService).getUserName("SomeId");
    }
}

注意上面最后一行,是验证 nameService 的 getUserName 被调用,并且参数为 "SomeId"。更多关于 Mockito 的内容,可以参考 Mockito 的文档

契约测试

契约测试会给每个服务生成一个 Stub,可以用于调用方的单元/集成测试。例如,我们需要测试预约服务的预约操作,而预约操作会调用用户服务,去验证用户的一些基本信息,比如医生是否认证等。

所以,我们可以通过传入不同的用户 ID,让契约 Stub 返回不同状态的用户数据,从而验证不同的处理流程。例如,正常的预约流程的测试用例可能是这样的。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(repositoryRoot="http://<nexus_root>",
        ids = {"com.xingren.service:user-client-stubs:1.0.0:stubs:6565"})
public class BookingTest {
    // BookingService 会调用用户服务,获取医生认证状态后进行不同的处理
    @Inject 
    private BookingService bookingService;
    @Test
    public void testBooking() {
        BookingForm form = new BookingForm(
            1,                      // doctorId
            1,                      // scheduleId
            1001);                  // patientId
        BookVO res = bookingService.book(form);
        assertTrue(res.id > 0);
        assertTrue(res.payStatus == PayStatus.UN_PAY);
    }
}

注意上面的 AutoConfigureStubRunner 注解就是设置并启动了用户服务 Stub,当然在测试的时候,我们需要把服务调用接口的 baseUrl 设置为http://localhost:6565。关于契约测试的更多内容,请参考微服务环境下的集成测试探索一文。

TDD

简单说下 Test Driven Development,也就是 TDD。左耳朵耗子就写了一篇TDD并不是看上去的那么美,我就直接引用其介绍了。

其开发过程是从功能需求的test case开始,先添加一个test case,然后运行所有的test case看看有没有问题,再实现test case所要测试的功能,然后再运行test case,查看是否有case失败,然后重构代码,再重复以上步骤。

其实严格的 TDD 流程实用性并不高,左耳朵耗子本身也是持批判态度。但是对于接口定义比较明确的模块,先写单元测试再写实现代码还是有很大好处的。因为目标清晰,而且可以立刻得到反馈。

如何设计单元测试?

单元测试设计方法

单元测试用例,和普通测试用例的设计,没有太多不同,常见的就是等价类划分、边界值分析等。而测试用例的设计其实也是开发者应该掌握的基本技能。

等价类划分

把所有输入划分为若干分类,从每个分类中选取少数有代表性的数据做为测试用例。

例如,一个方法计算输入参数的绝对值的倒数,如果是输入是 0,则抛异常。那么对这个方法写测试的话,就应该有三个等价类,输入是负数、0 以及正数。所以我可以选取一个负数、一个正数以及 0 来设计三个测试用例。

再举个例子,某个方法是根据医生的认证状态,发送不同的消息。那么等价类可能有三种,未认证、普通认证但无权威认证、普通认证且权威认证,某些情况下可能还会包括无普通认证但有威认证。

边界值分析

边界值是指划分等价类后,在边界附近的一些输入数据,这些输入往往是最容易出错的。

例如,对于上面计算绝对值的倒数的例子,那么边界值就包括 Integer.min、-1、0、1、Integer.max 等。再举个例子,文本框输入范围为 1 - 255 个字符,那么等价类按输入长度划分有三类 0、1 到 255、大于 255,而边界值则包括 0、1、2、254、255、256 等。

其他类似于空数组、数组的第一个和最后一个、报表的第一行和最后一行等等,也是属于边界值,需要特别关注。

判定表法

当我们由多个输入数据时,可以将这些数据的等价类的组合以表格的形式列举出来,然后设计测试用例。下面是一个例子(没有完全列举)。

用例 医生是否设置需要确认 医生是否设置免费咨询 医生是否已经确认患者 患者是否已经完善信息 期望结果
用例A 患者可以咨询医生
用例B 患者不能咨询医生
用例C 患者可以咨询医生
用例D 患者不能咨询医生
用例E 患者不能咨询医生

其他方法

除了上面提到的几种,测试设计方法还有几种常用的:
- 场景法。场景法是根据模块实际使用的场景,例如 API 的实际调用方法、系统的实际需求场景和处理逻辑创建的用例。这种方法比较直观,并且用例贴近实际需求的,不可忽视。
- 错误推测。错误推测其实就是凭直觉,考虑最容易出错的情况来设计用例。例如,我们直到新用户、重复请求、并发、弱网、大数据量等情况都是非常容易出错的,那么可以针对性的设计用例。错误推测需要测试设计者比较熟悉业务逻辑,并且经验丰富。

其他还有因果图、正交法等方法,这里就不说了。

覆盖率

如果按照前面的用例设计方法,可能会设计出很多用例。我们不可能也没有必要把每一个用例都写成单元测试。

怎么确认用例是否足够呢?一个很重要的参考指标就是代码覆盖率。

覆盖率指标

常用的覆盖率指标有四种:
- 语句覆盖:每条语句至少执行一次。
- 分支覆盖:每个分支至少有一次为真、一次为假。
- 条件覆盖:每个分支的每个条件至少有一次为真、一次为假。
- 路径覆盖:对所有的分支、循环等可能的路径,至少都要覆盖一次。

我们以这个简单的代码为例,看看这四种覆盖率到底是什么意思。

if (a && b) {
    // X    
}
// Y
if (c || d) {
    // X
}
  • 语句覆盖。只需要一个测试用例,让 a && bc || d 都为真,系统会依次执行 X、Y、Z 三个的代码段,就能做到语句覆盖。
  • 分支覆盖。至少需要两个测试用例,让 a && bc || d 都各为真假,例如用例1 a && b 为真和 c || d 为假,用例2 则反过来,既可让两个条件分支都各为真一次,为假一次。
  • 条件覆盖。至少需要四个测试用例,条件 a 和 b 的四种组合都要执行一次,条件 c 和 d 的四种组合也都要执行一次。
  • 路径覆盖。至少需要八个测试用例,条件 a、b、c 和 d 的所有组合都要执行一次。

可以看到,要做到条件覆盖甚至路径覆盖,会需要非常多的测试用例。一般情况,对于复杂的逻辑,单元测试做到分支覆盖就不错了,必要的话再做更多完全的覆盖。

Jacoco 覆盖

Jacoco 的覆盖率略有不同,这里简单说一下。
- 指令覆盖(Instructions),覆盖所有的 Java 代码指令。
- 分支覆盖(Branches),和上面的分支覆盖基本是一样的。
- 圈复杂度覆盖(Cyclomatic Complexity),可以认为就是路径覆盖率。
- 语句覆盖(Lines),和上面的语句覆盖基本是一样的。
- 方法覆盖(Methods),覆盖所有的方法。
- 类覆盖(Classes),覆盖所有的类。

怎么写有效的单元测试?

到现在,相信大家对怎么写单元测试应该有一定概念了。但是很多人也会有疑问:
- 单元测试耗费太多时间,会不会降低生产效率?
- 单元测试会不会很难维护?比如修改代码时还总是需要修改单元测试。

关于第一个问题,相信大家应该都能理解,如果我们在开发时发现 BUG,那么解决它是很容易的;但是一旦到了集成、验收甚至上线之后,那么要解决它就要花费比较大的代价了。业界很早就有共识,并且有不少数据可以证明,有效的单元测试虽然要花费更多编码时间,但是可以很大的减少项目的集成、测试和维护成本。

注意上面提到很重要一点是,单元测试必须是有效的,如果我们发现单元测试很难维护,那往往是因为我们没有写出有效的单元测试。

不是所有的代码都需要单元测试

写单元测试我们也需要考虑投入产出比,例如下面这些情况,写单元测试的投入产出比可能会较差。
- 短期的或者一次性的项目,例如 Demo、数据更新脚本。
- 业务简单的,不含太多逻辑的模块。例如获取或者查找一个数据,或者没有分支条件的业务逻辑等。
- UI 层,相对而言比较难做单元测试,除非 UI 本身就有比较复杂的逻辑(其实某些 UI 框架也提供了单元测试工具)。

那么那些情况下要写单元测试呢?简单来说,就是两类。
- 逻辑复杂、不容易理解、容易出错的模块。例如,计算闰年的方法、订单下单等。
- 公共模块或者核心的业务模块。

即使对于需要写单元测试的模块,我们也应该关注最核心最重要的测试用例,而没必要单纯的追求覆盖率,或者追求条件覆盖甚至路径覆盖,一般做到分支覆盖就可以了。另外一个有效的方法是,对于出现的每一个 BUG,添加一个单元测试。

单元测试应该是稳定的

这里稳定的第一个含义是,单元测试不应该经常需要修改。如果单元测试经常因为底层实现逻辑的变动而需要修改,那一定不是好的单元测试。也就是说,被测单元的接口应该是稳定的、设计良好的、易于扩展的。

稳定的第二个含义是,单元测试的结果应该是稳定的。如果在不同的环境、不同的情况运行单元测试,会返回不同的结果,那就不是好的单元测试。如果测试需要依赖特定的数据、文件等,那需要有前置的初始化脚本确保依赖的数据、文件在所有环境都存在并且是一致的。

单元测试应该是灰盒测试

单元测试应该覆盖核心逻辑的各种分支、边界及异常,但是避免涉及易变的实现逻辑。也就是说,我们不应该把单元测试当成完全的白盒测试,但也不是黑盒测试,而应该把它当成介于白盒和黑盒之间的灰盒测试。

被测代码应该是抽象良好的

如果我们发现一段代码很难编写单元测试,常常是因为这段代码没有符合良好的抽象规范,比如没有使用 DI、不符合单一职责原则、或者依赖了全局的公共变量和方法等等。我们可以考虑优化这段代码,再来尝试单元单元测试。

谈谈到底什么是抽象,以及软件设计的抽象原则 介绍了软件抽象的原则,这里就不再重复了。

编码时就应该同时写好单元测试

这样我们才能在调试时就发挥单元测试的优势,对代码的任何修改都能得到即时反馈。如果是后面再补充单元测试,一方面对实现可能已经不太熟悉了,编写测试的代价更大了;另一方面,单元测试能发挥的作用也变小了。不过即使这样,对那些需要长远维护的项目,编写单元测试也还是很有用的。

单元测试的代码质量也很重要

单元测试也是代码,也是需要不断维护的。所以我们不应该随随便便的去写单元测试,而是要把他们也当成普通代码一样,要做到高质量、模块化、可维护。

为什么要写单元测试之终极原因

终极原因是,作为一名优秀的工程师,如果被 QA 和产品经理 Challenge 有 BUG,能忍吗?而我们工程师当然要用工程师 Style 的测试方法,那就是自动化的单元测试了,不是吗?

参考

Software Testing Anti-patterns

0

我们正在招聘Java工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注