如何让单元测试的价值最大化
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、空串、非空串”,子串起始位置 start
为 int
型,可选:“正数(相对位置从左往右)、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 ";
}
我们从代码中的分支语句(例如 if
、for
、while
等)寻找边界值。这段代码有 4 个 if
语句,意味着有 4 个边界值,因此我们至少需要设计 8 个用例。在这个例子中,相比黑盒法,白盒法得到的边界更多,测试用例也就更多。我们不枚举所有用例,只列举 2 个不存在于上述黑盒法的用例:
- 用例 5:开始前 3 天,预期显示 3d
- 边界来源:Line 4,
if (days == 2)
- 解读:当倒计时时间超出 24h 且刚好为整数天时,不需要向上取整
+1
- 边界来源:Line 4,
- 用例 6:开始前 1 天 1 秒,预期显示 24h:0m:1s
- 边界来源:Line 13,
if (days == 1)
- 解读:当剩余天数为 1 时,倒计时小时数需要
+24
- 边界来源:Line 13,
由此可见,白盒法有着它的独特优势:由于代码可见,我们通过分析代码结构,可以找到程序的真实边界,这是黑盒测试所做不到的。因此,从边界测试角度看,白盒测试的覆盖率更高,因而 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 的 实体依赖:例如数据类,只有简单的
getter
、setter
方法,没有复杂处理逻辑。 - 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 测试。
- 单元测试:对应用的各个类,包括
controller
、service
、repository
、model
等,分别(或者两三个组合在一起)进行测试。
从测试效率角度来看,由于 API 测试工具的成熟度,集成测试的编写和执行效率未必比单元测试低多少;从测试有效性角度来看,如果 controller
、service
、repository
、model
等类都没有复杂的处理逻辑,只是承担简单的数据封装和代理职责,那么集成测试完全可以实现与单元测试相当的测试覆盖度。这种情况下,我们还是一定要做单测吗?
这就是测试金字塔或者单元测试容易遭受挑战的场景。应该说,我们反对 “唯单测论”,反对教条主义式做单测,而是要回归单测本质,在真正需要单测的场景下做单测。那么,什么样的场景需要单测(甚至单测是不二选择)呢?那就是本文前面几节中提到的场景:
- 逻辑复杂:我们的被测对象不是只有简单的 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 个问题,将是我们能否把单元测试真正做好并且最大化其价值的关键所在。