赞
踩
关于 “什么是单元测试”、“为什么要做单元测试”、“怎么做单元测试”,网络上相关的技术文章汗牛充栋。尽管如此,在推广单元测试的过程,通过与研发同学的交流,我发现大家对单元测试的探讨还是存在薄弱的地方。这个薄弱的地方既不是抽象的单元测试理论,也不是具体的单元测试工具,而是理论与实践结合的 单元测试策略。
就像测试策略一样,单元测试策略决定了我们能否把单元测试真正做好(而不是流于形式),并且让单元测试产生的价值最大化(而不是与集成测试做类似甚至重复的事情)。
本文讨论的单元测试策略不是空泛的,而是来自于单元测试实践中遇到的真实问题,即:
接下来,我们就来逐一分析这 5 个问题,并探索这些问题的解决之道。
单元测试做得好不好,根本上不在于用多么先进的测试框架、测试工具,而在于 测试用例的有效性,即用例是否覆盖了它应该覆盖的东西。如何设计有效的单测用例?这并不是一个唾手可得的技能。只要留意一下大家日常是怎么设计测试用例的,就知道了。
有的人依靠 “直觉” 或 “经验”,想到什么用例,就测什么用例(可以认为没有测试设计这一环节);有的人盯着代码覆盖率数据,目标是多少就测到多少(为了完成 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% 的覆盖度不是必须的)或对被测代码质量有信心(这种信心建立在知道自己测了什么、没测什么的基础上,是真正的信心,而不是盲目自信)为止。
有人也许会说,这个方法是否过于繁琐、成本太高?事实上,如下图所示,根据二八原则,对于绝大部分逻辑简单的方法,只需要简单设计用例就可以了。只有少数长尾的、逻辑复杂的(特征:代码中分支多、判断条件多、执行路径多)的方法才需要严谨地设计用例。事实上,它们也值得这样测试,因为 逻辑复杂往往意味着更高的出错可能性和质量风险。
小结:针对逻辑复杂的代码模块,综合运用黑盒测试和白盒测试方法设计测试用例,从而提升单测的覆盖度和有效性。
软件测试,说一千道一万,它的根本目的是 发现软件 BUG。投资大师芒格有句名言,“要去鱼多的地方捕鱼”。同理,对于测试来说,我们要去 BUG 多的地方找 BUG。
那么,什么地方 BUG 多呢?经验告诉我们,边界场景 BUG 多。这里的边界场景是相对于主干流程(即程序的 happy path
)而言的,它包含了程序的各种 分支(branch
)、角落(corner
)、边缘(edge
)、异常(exceptional
)、无效(invalid
)场景。那么,如何在边界场景找 BUG 呢?这就要用到边界测试(boundary testing
)方法。
如何开展边界测试?如图所示,边界测试通常有三步:
边界测试的核心在于第 1 步,即 寻找边界。
如何寻找边界呢?有两种方法。一种是黑盒法,从需求中寻找边界。另一种是白盒法,从代码中寻找边界。
举个例子。假设我们有这样一个需求,实现一个倒计时,展示距离某大型活动开幕的时间,要求如下:
黑盒法:直接从需求中寻找边界。
根据需求描述,我们可以推导出 2 个边界:(1)活动开始前 48 小时;(2)活动开始后。边界的意义在于将测试空间划分为两个等价分区。对于每一个边界,我们通常需要设计 2 个用例:
on point
,刚好处于边界上的点(if
条件为 True
)off point
,离边界点最近且处于边界外(if
条件为 False
)的点因此,针对上述 2 个边界,我们可以设计 4 个测试用例:
白盒法:从被测代码中寻找边界。
以下是实现上述倒计时需求的代码示例。
// 略:根据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 个不存在于上述黑盒法的用例:
if (days == 2)
+1
if (days == 1)
+24
由此可见,白盒法有着它的独特优势:由于代码可见,我们通过分析代码结构,可以找到程序的真实边界,这是黑盒测试所做不到的。因此,从边界测试角度看,白盒测试的覆盖率更高,因而 BUG 发现能力也更强。当然,这并不意味着黑盒法可以被白盒法完全替代。就像用例设计一样,在进行边界测试时,我们也是需要综合运用黑盒和白盒两种方法。
从边界测试角度,我们还可以发现另外一个有趣的结论。我们推动研发开展单元测试,并不只是为了提升测试效率(相比黑盒的集成测试、系统测试,单元测试有更快的运行速度、更低的排错成本、更及时的质量反馈),更是为了提升测试有效性(相比黑盒测试,单元测试由于代码可见性,有能力去更全面地覆盖真实存在的、容易隐藏 BUG 的各种边界场景)。
相比集成测试或系统测试,单元测试的一个重要特点是非常依赖 Mock。所谓 Mock,就是 用模拟对象替换被测代码的依赖,它本质上是测试环境的一部分。
对于集成测试或系统测试,测试环境通常是真实的,并且不同用例共享同一套测试环境。对于单元测试来说,测试环境通常是 Mock 的,并且不同用例由于被测代码依赖的差异,可能使用完全不同的 Mock。几乎可以认为:无 Mock,不单测。
做好单测,重点要做好 Mock。然而,Mock 并不是一件容易的事情。举一个例子,已知有两个类 A 和 B,A 是依赖 B 的。
如下图所示,对 A 进行 Mock 测试,包括 4 个步骤:
B.doSomething
。创建 Mock 的前提是搞清楚 A 和 B 之间的契约:A 是怎么调用 B 的,B 又返回什么样的结果给 A。
从这个例子可以看出,Mock 测试依赖于两个重要的前提:
契约和代码可测性都属于 代码设计 层面的问题。如果这两个问题在代码设计阶段没有处理好,那么在代码实现和测试阶段,任何努力都是于事无补的。为什么单元测试推广难?一个重要原因就是存在 历史遗留代码,它们在契约设计和可测试设计方面存在着巨大的技术债,导致针对这些代码编写单测用例时困难重重,除非进行 代码重构。
在实践中,对于 Mock 测试,除了契约和可测性,还有一个问题需要考虑清楚:当测试某个类时,是否要将它的所有依赖类全部 Mock?
答案显然是 NO。那么,下一个问题来了,什么样的依赖需要 Mock,什么样的依赖不需要 Mock 呢?这个问题没有标准答案,但是有些经验法则可以参考。
当 A 依赖 B 时,以下情形,不建议 Mock B:
getter
、setter
方法,没有复杂处理逻辑。当 A 依赖 B 时,以下情形,建议 Mock B:
总之,做好单测的重点在于做好 Mock 测试,而 Mock 测试强依赖于代码设计,包括 契约设计、可测性设计。并且,在进行 Mock 测试时,我们需要根据上下文,决策是否要对每一个具体的依赖类进行 Mock。
根据上述讨论,单元测试并不是一定要 Mock 的。如果我们同时测试了两个类 A 和 B,甚至更多类,那么我们做的到底是单元测试还是集成测试呢?应该说,单元测试和集成测试没有绝对的分界。那么,在实践中,单元测试和集成测试到底应该如何分工合作呢?或者说,如果我们已经有了充分的集成测试,是否一定需要补充单测呢?
举一个在微服务架构下的常见例子。如下图所示,有一个 CRUD 应用,假设它的功能十分简单,就是提供某种资源(resource
)的增、删、改、查功能。针对这个应用,有两种测试策略:
controller
、service
、repository
、model
等,分别(或者两三个组合在一起)进行测试。
从测试效率角度来看,由于 API 测试工具的成熟度,集成测试的编写和执行效率未必比单元测试低多少;从测试有效性角度来看,如果 controller
、service
、repository
、model
等类都没有复杂的处理逻辑,只是承担简单的数据封装和代理职责,那么集成测试完全可以实现与单元测试相当的测试覆盖度。这种情况下,我们还是一定要做单测吗?
这就是测试金字塔或者单元测试容易遭受挑战的场景。应该说,我们反对 “唯单测论”,反对教条主义式做单测,而是要回归单测本质,在真正需要单测的场景下做单测。那么,什么样的场景需要单测(甚至单测是不二选择)呢?那就是本文前面几节中提到的场景:
归根结底,单元测试的优势在于对代码逻辑的深度覆盖,而集成测试的优势在于对组件交互的广度覆盖。
在实践中,我们要有全局意识,统筹考虑单元测试和集成测试,在必要的时候,随时准备从单元测试切换到集成测试、或者从集成测试切换到单元测试。
正如这张图所示:
做任何事情,总是离不开度量,单测也不例外。如何衡量单测做得好不好?够不够?大家都知道一些覆盖率度量指标。但是,这些指标之间是什么关系?在什么发展阶段选择什么样的度量指标?这个问题讨论得不够充分。
单元测试覆盖率,典型的度量指标有 行覆盖率、分支覆盖率、路径覆盖率 和 mutation 覆盖率。我对它们的理解如下:
mutation 覆盖率来源于 mutation 测试,即 变异测试。如下图所示,它故意修改程序源代码,将 if(a == b || b == 1)
修改成 if(a != b || b == 1)
,然后执行测试用例,观察是否有用例失败。修改源代码的操作叫做 注入 mutant。如果有用例失败,则说明这个 mutant 被 kill
掉,符合预期;否则,这个 mutant 存活,不符合预期。
mutation 覆盖率表示被 kill
掉的 mutant 的比例,其数值越高,说明用例的 BUG 发现能力越强,测试的有效性越高。因此,通常认为 mutation 覆盖率是一种比行覆盖率、分支覆盖率更严格,并且切实可行的单测有效性度量指标。
在实践单测时,不同发展阶段我们关注的度量指标不同:
本文探讨了单元测试中的 5 个关键策略问题:用例设计问题、边界测试问题、Mock 测试问题、与集成测试的分工问题、度量问题,并给出了作者的解决之道。一家之言,仅供参考。
个人认为,能否解决好这 5 个问题,将是我们能否把单元测试真正做好并且最大化其价值的关键所在。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。