【单元测试】如何让单元测试的价值最大化

1.背景

关于 “什么是单元测试”、“为什么要做单元测试”、“怎么做单元测试”,网络上相关的技术文章汗牛充栋。尽管如此,在推广单元测试的过程,通过与研发同学的交流,我发现大家对单元测试的探讨还是存在薄弱的地方。这个薄弱的地方既不是抽象的单元测试理论,也不是具体的单元测试工具,而是理论与实践结合的 单元测试策略

就像测试策略一样,单元测试策略决定了我们能否把单元测试真正做好(而不是流于形式),并且让单元测试产生的价值最大化(而不是与集成测试做类似甚至重复的事情)。

本文讨论的单元测试策略不是空泛的,而是来自于单元测试实践中遇到的真实问题,即:

  • 用例设计问题
  • 边界测试问题
  • Mock 测试问题
  • 与集成测试的分工问题
  • 度量问题

接下来,我们就来逐一分析这 5 个问题,并探索这些问题的解决之道。

2.用例设计问题

单元测试做得好不好,根本上不在于用多么先进的测试框架、测试工具,而在于 测试用例的有效性,即用例是否覆盖了它应该覆盖的东西。如何设计有效的单测用例?这并不是一个唾手可得的技能。只要留意一下大家日常是怎么设计测试用例的,就知道了。

有的人依靠 “直觉” 或 “经验”,想到什么用例,就测什么用例(可以认为没有测试设计这一环节);有的人盯着代码覆盖率数据,目标是多少就测到多少(为了完成 KPI 而做单测,达到指标了就万事大吉);还有人直接用工具自动生成用例(而不关心这些用例究竟测了什么,没测什么)。

能不能用这些方法做单测呢?当然能,毕竟这样做可能比完全不做单测要好一些。但是这样能把单测做好吗?未必。

和任何其他类型的测试一样,单测做得好不好,不在于我们是怎么测的(how),而在于我们测了什么(what)。然而,“测什么”,“不测什么”,这是测试设计阶段要解决的问题,单测也不例外。因此,在写单测之前,我们需要认真设计一下测试用例。那么,如何设计单测用例呢?这就要回归到测试的基础理论:黑盒测试白盒测试

在这里插入图片描述

  • 黑盒测试:将被测代码当作黑盒,基于程序对外提供的功能(包括它的输入、输出、以及输入输出作用关系)设计测试用例。典型方法包括边界值分析、等价类划分、决策树、状态机转换等。
  • 白盒测试:将被测代码当作白盒,基于程序内部的实现结构(包括条件、分支、循环等语句)设计测试用例。典型方法有语句覆盖、分支覆盖、条件覆盖、代码路径覆盖等。

显然,黑盒法和白盒法各有特点。对于单元测试来说,如下图所示,综合运用黑盒测试和白盒测试两种方法进行用例设计,是一种提升用例有效性的办法。
在这里插入图片描述
以 Apache 开源的 Commons Lang 库中的子串函数 StringUtils.substring(String str, int start) 为例,来说明图示方法的核心思路。该函数的源代码如下图所示,给定字符串 str 和子串起始位置 start,这个函数返回对应的子串。

public static String substring(String str, int start) {
    if (str == null) {
        return null;
    } else {
        if (start < 0) {
            start += str.length();
        }
        if (start < 0) {
            start = 0;
        }
        return start > str.length() ? "" : str.substring(start);
    }
}

那么,如何运用上述方法设计这个函数的单测用例呢?核心有两点。

首先,利用黑盒测试方法设计用例。

将被测程序当作黑盒,利用输入输出关系设计用例。分析入参特征:字符串 str 为 String 类型,可选:“NULL空串非空串”,子串起始位置 startint 型,可选:“正数(相对位置从左往右)0负数(相对位置从右往左)”。分析入参约束关系:start 可以在 str 范围内、范围外。根据决策表法,枚举入参组合作为测试用例。由于组合情况多,运用等价类划分法精简用例。

设计用例如下:

// 字符串为null
assertEquals(null,  StringUtils.substring(null,   0));
// 字符串为空
assertEquals("",    StringUtils.substring("",     0));
// 字符串非空,且起始位置在字符串开头(从左往右)
assertEquals("abc", StringUtils.substring("abc",  0));
// 字符串非空,且起始位置在字符串结尾(从左往右)
assertEquals("c",   StringUtils.substring("abc",  2));
// 字符串非空,且起始位置超出字符串范围(从左往右)
assertEquals("",    StringUtils.substring("abc",  3));
// 字符串非空,且起始位置在字符串开头(从右往左)
assertEquals("c",   StringUtils.substring("abc", -1));
// 字符串非空,且起始位置在字符串结尾(从右往左)
assertEquals("abc", StringUtils.substring("abc", -3));

然后,利用代码覆盖结果增强用例。

以上用例是否覆盖完善呢?基于白盒测试,收集并分析被测代码行、分支、条件覆盖情况。结果发现,源代码第 9 行 (start = 0) 未覆盖,反映 “字符串非空,且起始位置超出字符串范围(从右往左)” 这一场景漏测了,为此增加一个用例:assertEquals("abc", StringUtils.substring("abc", -4)) 进行针对性覆盖。

重复上述过程,直到覆盖完善( 100 % 100\% 100% 的覆盖度不是必须的)或对被测代码质量有信心(这种信心建立在知道自己测了什么、没测什么的基础上,是真正的信心,而不是盲目自信)为止。

有人也许会说,这个方法是否过于繁琐、成本太高?事实上,如下图所示,根据二八原则,对于绝大部分逻辑简单的方法,只需要简单设计用例就可以了。只有少数长尾的、逻辑复杂的(特征:代码中分支多、判断条件多、执行路径多)的方法才需要严谨地设计用例。事实上,它们也值得这样测试,因为 逻辑复杂往往意味着更高的出错可能性和质量风险

在这里插入图片描述

小结:针对逻辑复杂的代码模块,综合运用黑盒测试和白盒测试方法设计测试用例,从而提升单测的覆盖度和有效性。

3.边界测试问题

软件测试,说一千道一万,它的根本目的是 发现软件 BUG。投资大师芒格有句名言,“要去鱼多的地方捕鱼”。同理,对于测试来说,我们要去 BUG 多的地方找 BUG。

那么,什么地方 BUG 多呢?经验告诉我们,边界场景 BUG 多。这里的边界场景是相对于主干流程(即程序的 happy path)而言的,它包含了程序的各种 分支branch)、角落corner)、边缘edge)、异常exceptional)、无效invalid)场景。那么,如何在边界场景找 BUG 呢?这就要用到边界测试(boundary testing)方法。

如何开展边界测试?如图所示,边界测试通常有三步:

  • 寻找边界
  • 分析边界值
  • 设计边界用例

边界测试的核心在于第 1 步,即 寻找边界

在这里插入图片描述
如何寻找边界呢?有两种方法。一种是黑盒法,从需求中寻找边界。另一种是白盒法,从代码中寻找边界

举个例子。假设我们有这样一个需求,实现一个倒计时,展示距离某大型活动开幕的时间,要求如下:

  • 超过 48 小时,展示向上取整天数
  • 48 小时及以内展示时分秒
  • 活动开幕后,倒计时消失

黑盒法:直接从需求中寻找边界。

根据需求描述,我们可以推导出 2 个边界:(1)活动开始前 48 小时;(2)活动开始后。边界的意义在于将测试空间划分为两个等价分区。对于每一个边界,我们通常需要设计 2 个用例:

  • 正点用例on point,刚好处于边界上的点(if 条件为 True
  • 偏离点用例off point,离边界点最近且处于边界外(if 条件为 False)的点

因此,针对上述 2 个边界,我们可以设计 4 个测试用例:

  • 边界 1:活动开始前 48 小时
    • 用例 1:开始前 48 小时 1 秒,预期显示 3d
    • 用例 2:开始前 48 小时,预期显示 48h:0m:0s
  • 边界 2:活动开始后
    • 用例 3:开始时,预期显示 0h:0m:0s
    • 用例 4:开始后 1 秒,预期倒计时消失

白盒法:从被测代码中寻找边界。

以下是实现上述倒计时需求的代码示例。

// 略:根据diff时间,计算剩余days、hours、minutes、seconds
if (days >= 2) {
  if ((hours + minutes + seconds) == 0) {
    if (days == 2) {
      text = "48h 0m 0s";
    } else {
      text = days + "d";
    }
  } else {
    text = (days + 1) + "d";
  }
} else {
  if (days == 1) {
    hours = hours + 24;
  }
  text = hours + "h " + minutes + "m " + seconds + "s ";
}

我们从代码中的分支语句(例如 ifforwhile 等)寻找边界值。这段代码有 4 个 if 语句,意味着有 4 个边界值,因此我们至少需要设计 8 个用例。在这个例子中,相比黑盒法,白盒法得到的边界更多,测试用例也就更多。我们不枚举所有用例,只列举 2 个不存在于上述黑盒法的用例:

  • 用例 5:开始前 3 天,预期显示 3d
    • 边界来源:Line 4,if (days == 2)
    • 解读:当倒计时时间超出 24h 且刚好为整数天时,不需要向上取整 +1
  • 用例 6:开始前 1 天 1 秒,预期显示 24h:0m:1s
    • 边界来源:Line 13,if (days == 1)
    • 解读:当剩余天数为 1 时,倒计时小时数需要 +24

由此可见,白盒法有着它的独特优势:由于代码可见,我们通过分析代码结构,可以找到程序的真实边界,这是黑盒测试所做不到的。因此,从边界测试角度看,白盒测试的覆盖率更高,因而 BUG 发现能力也更强。当然,这并不意味着黑盒法可以被白盒法完全替代。就像用例设计一样,在进行边界测试时,我们也是需要综合运用黑盒和白盒两种方法。

从边界测试角度,我们还可以发现另外一个有趣的结论。我们推动研发开展单元测试,并不只是为了提升测试效率(相比黑盒的集成测试、系统测试,单元测试有更快的运行速度、更低的排错成本、更及时的质量反馈),更是为了提升测试有效性(相比黑盒测试,单元测试由于代码可见性,有能力去更全面地覆盖真实存在的、容易隐藏 BUG 的各种边界场景)。

4.Mock 测试问题

相比集成测试或系统测试,单元测试的一个重要特点是非常依赖 Mock。所谓 Mock,就是 用模拟对象替换被测代码的依赖,它本质上是测试环境的一部分

对于集成测试或系统测试,测试环境通常是真实的,并且不同用例共享同一套测试环境。对于单元测试来说,测试环境通常是 Mock 的,并且不同用例由于被测代码依赖的差异,可能使用完全不同的 Mock。几乎可以认为:无 Mock,不单测

做好单测,重点要做好 Mock。然而,Mock 并不是一件容易的事情。举一个例子,已知有两个类 A 和 B,A 是依赖 B 的。

在这里插入图片描述
如下图所示,对 A 进行 Mock 测试,包括 4 个步骤:

  • Mock 创建(Line 3-4):创建 Mock B。具体来说,是 Mock 对象 B 中被 A 所依赖的方法 B.doSomething。创建 Mock 的前提是搞清楚 A 和 B 之间的契约:A 是怎么调用 B 的,B 又返回什么样的结果给 A。
  • Mock 注入(Line 6):实例化被测类 A,同时注入第 1 步创建好的 Mock B。这一步的目的是确保 A 调用的是 Mock 的 B,而不是真实的 B。
  • Mock 使用(Line 8):对 A 进行测试,验证 A 的行为是否符合预期。A 在运行过程中,会调用依赖的 B。此时,由于第 2 步的注入动作,A 调用的是 Mock 的 B。
  • Mock 校验(Line 10):验证 A 是否调用了 Mock 的 B,并且是否以预期的入参调用了 Mock 的 B。这一步的本质是验证 A 是否遵守了 A 和 B 之间的契约。

在这里插入图片描述
从这个例子可以看出,Mock 测试依赖于两个重要的前提:

  • 契约:契约是 Mock 的基础,没有契约就无法创建 Mock。
  • 代码可测性:可测性是 Mock 的关键,被测类的依赖必须是独立的、可控制的(依赖注入原则)。可测性不好,会导致无法注入 Mock

契约和代码可测性都属于 代码设计 层面的问题。如果这两个问题在代码设计阶段没有处理好,那么在代码实现和测试阶段,任何努力都是于事无补的。为什么单元测试推广难?一个重要原因就是存在 历史遗留代码,它们在契约设计和可测试设计方面存在着巨大的技术债,导致针对这些代码编写单测用例时困难重重,除非进行 代码重构

在实践中,对于 Mock 测试,除了契约和可测性,还有一个问题需要考虑清楚:当测试某个类时,是否要将它的所有依赖类全部 Mock

答案显然是 NO。那么,下一个问题来了,什么样的依赖需要 Mock,什么样的依赖不需要 Mock 呢?这个问题没有标准答案,但是有些经验法则可以参考。

当 A 依赖 B 时,以下情形,不建议 Mock B:

  • B 是 A 的 本地依赖:例如系统内置类(ArrayList 等)、Utility 类(StringUtils 等)。
  • B 是 A 的 简单依赖:B 的逻辑非常简单。
  • B 是 A 的 实体依赖:例如数据类,只有简单的 gettersetter 方法,没有复杂处理逻辑。
  • B 是 A 的 独占依赖:B 只被 A 依赖,不被其他类依赖。

当 A 依赖 B 时,以下情形,建议 Mock B:

  • B 是 A 的 外部依赖:例如外部 HTTP 服务,需要复杂的设置或返回结果不可控。
  • B 是 A 的 复杂依赖:B 的逻辑复杂,返回结果有很多情形。
  • B 是 A 的 慢依赖:B 的某些行为很慢,例如存在等待、超时。
  • B 是 A 的 共享依赖:B 不止被 A 依赖,还被 C、D、E 依赖。

总之,做好单测的重点在于做好 Mock 测试,而 Mock 测试强依赖于代码设计,包括 契约设计可测性设计。并且,在进行 Mock 测试时,我们需要根据上下文,决策是否要对每一个具体的依赖类进行 Mock。

5.与集成测试的分工问题

根据上述讨论,单元测试并不是一定要 Mock 的。如果我们同时测试了两个类 A 和 B,甚至更多类,那么我们做的到底是单元测试还是集成测试呢?应该说,单元测试和集成测试没有绝对的分界。那么,在实践中,单元测试和集成测试到底应该如何分工合作呢?或者说,如果我们已经有了充分的集成测试,是否一定需要补充单测呢?

举一个在微服务架构下的常见例子。如下图所示,有一个 CRUD 应用,假设它的功能十分简单,就是提供某种资源(resource)的增、删、改、查功能。针对这个应用,有两种测试策略:

  • 集成测试:将应用作为一个整体,测试应用对外提供的的 API。此时,集成测试也叫接口测试、API 测试。
  • 单元测试:对应用的各个类,包括 controllerservicerepositorymodel 等,分别(或者两三个组合在一起)进行测试。

在这里插入图片描述
从测试效率角度来看,由于 API 测试工具的成熟度,集成测试的编写和执行效率未必比单元测试低多少;从测试有效性角度来看,如果 controllerservicerepositorymodel 等类都没有复杂的处理逻辑,只是承担简单的数据封装和代理职责,那么集成测试完全可以实现与单元测试相当的测试覆盖度。这种情况下,我们还是一定要做单测吗?

这就是测试金字塔或者单元测试容易遭受挑战的场景。应该说,我们反对 “唯单测论”,反对教条主义式做单测,而是要回归单测本质,在真正需要单测的场景下做单测。那么,什么样的场景需要单测(甚至单测是不二选择)呢?那就是本文前面几节中提到的场景:

  • 逻辑复杂:我们的被测对象不是只有简单的 CRUD 操作,而是有着更复杂的业务逻辑。
  • 边界测试:被测业务或代码有很多分支、组合场景,我们想把这些场景测试得更全面。
  • Mock 测试:被测代码有些外部依赖,我们想聚焦在被测代码上、避免测试受外部依赖干扰。

归根结底,单元测试的优势在于对代码逻辑的深度覆盖,而集成测试的优势在于对组件交互的广度覆盖。

在实践中,我们要有全局意识,统筹考虑单元测试和集成测试,在必要的时候,随时准备从单元测试切换到集成测试、或者从集成测试切换到单元测试

在这里插入图片描述
正如这张图所示:

  • 针对复杂的代码逻辑模块,选择单元测试。
  • 针对主干业务流程,选择集成测试。
  • 在做集成测试时,如果发现存在很多分支场景需要覆盖,那么考虑切换到单元测试。
  • 在做单元测试时,如果发现我们的测试脚本与被测代码重复度高(意味着我们过度分割了被测对象),那么考虑扩大被测范围,切换到集成测试。

6.单测度量问题

做任何事情,总是离不开度量,单测也不例外。如何衡量单测做得好不好?够不够?大家都知道一些覆盖率度量指标。但是,这些指标之间是什么关系?在什么发展阶段选择什么样的度量指标?这个问题讨论得不够充分。

单元测试覆盖率,典型的度量指标有 行覆盖率分支覆盖率路径覆盖率mutation 覆盖率。我对它们的理解如下:

  • 行覆盖率
    • 表示已经覆盖的代码行数的比例,是最基础的指标。
    • 一般来说,行覆盖率达到 85 % 85\% 85% 就已经是很高了。
  • 分支覆盖率
    • 表示已经覆盖的代码分支的比例。
    • 虽然分支覆盖率和行覆盖率是两个独立的指标,但是经验告诉我们,分支覆盖率通常比行覆盖率低。据我观察,当行覆盖率 80 80 80 90 % 90\% 90% 时,分支覆盖率通常只有 50 50 50 60 % 60\% 60%
    • 可以认为,分支覆盖率是一种比行覆盖率更严格的指标。
  • 路径覆盖率
    • 相比行覆盖率和分支覆盖率,还有一种更严格的指标,叫做路径覆盖率。
    • 它表示已经覆盖的代码执行路径的比例。
    • 由于组合爆炸,程序可能的执行路径是非常庞大、甚至无法穷举的。因此,路径覆盖率只是一个理论上的指标,在实际中几乎没有人使用。
  • mutation 覆盖率
    • 行覆盖率和分支覆盖率有一个共同特点,即关注已经覆盖的部分是没有意义的,要关注没有覆盖的部分:为什么没有覆盖?有没有风险?需不需要覆盖?如何增加用例来覆盖?
    • mutation 覆盖率关注的则是:对于已经覆盖的代码,是否实质覆盖了?
      • 表面覆盖:某一行代码被执行到了。
      • 实质覆盖:某一行代码被执行到了,并且当这一行代码中存在 BUG 时,测试用例会失败。

mutation 覆盖率来源于 mutation 测试,即 变异测试。如下图所示,它故意修改程序源代码,将 if(a == b || b == 1) 修改成 if(a != b || b == 1),然后执行测试用例,观察是否有用例失败。修改源代码的操作叫做 注入 mutant。如果有用例失败,则说明这个 mutant 被 kill 掉,符合预期;否则,这个 mutant 存活,不符合预期。

在这里插入图片描述

mutation 覆盖率表示被 kill 掉的 mutant 的比例,其数值越高,说明用例的 BUG 发现能力越强,测试的有效性越高。因此,通常认为 mutation 覆盖率是一种比行覆盖率、分支覆盖率更严格,并且切实可行的单测有效性度量指标

在实践单测时,不同发展阶段我们关注的度量指标不同:

  • 初级阶段
    • 在单元测试初级阶段,即研发团队开始引入和推广单元测试时,建议关注行覆盖率、分支覆盖率。
    • 尤其是分支覆盖率,更能体现单元测试价值:一些通过集成测试很难 touch 到的代码分支,通过单元测试可以 touch 到。
  • 高级阶段
    • 在单元测试高级阶段,即研发团队的单元测试逐渐成熟、行与分支覆盖率达到较高水平时,建议关注 mutation 覆盖率。
    • mutation 覆盖率可以度量测试用例的真实有效性,更好地驱动单测改进。

7.总结

本文探讨了单元测试中的 5 个关键策略问题:用例设计问题边界测试问题Mock 测试问题与集成测试的分工问题度量问题,并给出了作者的解决之道。一家之言,仅供参考。

个人认为,能否解决好这 5 个问题,将是我们能否把单元测试真正做好并且最大化其价值的关键所在。

  • 29
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

G皮T

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值