关于测试的第一部分 - 选择您的测试





5.00/5 (4投票s)
除了基本的单元测试之外,你如何选择测试,以及何时才算完成?
介绍
你刚刚写了一些代码,现在你坐在你最喜欢的测试框架前,想知道应该使用哪些测试来确认它能按预期工作,没有任何缺陷。在这篇文章中,我将引导你了解如何控制输入空间,以及组合方法如何提供一种强度度量,同时保持测试套件的合理规模。最后,我将探讨一种有用的验证捷径。
多年前,我反复发现自己在使用单元测试框架,而关键系统需要集成测试,我困惑于如何确保系统不包含任何严重的 bug。对我来说,当时的单元测试只是一个可以遍历函数指针列表(C# 中的委托)并报告调用函数时发生的任何异常的框架,但它没有给我任何关于如何向系统抛出什么或如何验证其按预期工作的线索。后来我了解到,单元测试还涉及一个过程和一种特定的工作方式(TDD),以及通过 BDD(又称“按示例规范”)扩展到规范/验收测试,例如在 SpecFlow 框架中。我还遇到了一些测试框架,它们提供数据驱动程序,以便每个测试可以每次使用不同的数据运行多次,但它们仍然没有帮助我选择如何测试或如何验证我的代码。
我决定深入研究,深入得多,最终构建了自己的功能齐全的测试应用程序,名为“Quality Gate One Studio”(QS),以帮助我测试正确性,还包括状态、异步行为、性能和并发性。在这篇文章中,我将分享我学到的一些知识。本文简要介绍了抽象分类、覆盖数组、随机数组和验证。下一篇文章将介绍有状态和异步系统以及场景模拟。最后一篇文章将讨论性能、时间等方面。
我希望你会喜欢这篇文章,并对测试这个主题获得更深入的见解。
你通常使用单元测试,特别是 TDD,进行白盒测试(又称“结构测试”):你检查代码并识别其结构,然后编写测试以确保你覆盖代码中的分支、循环等。使用 TDD,你反其道而行之,从测试开始,然后迭代这个过程,随着代码的开发而改进和扩展测试,因此实际上你实现了相同的事情——一个覆盖实现的白盒测试。
TDD 会让你的思维在编写代码和验证代码之间跳跃,你通常会得到一些非常坚固的代码,但作为一种白盒方法,它会验证你构建的内容,而不一定是你用户所期望的。
引入 BDD,它提供了一种让用户表达对代码期望的方式。BDD 提供了一种直截了当的语言,你可以用它来表达你的期望,你甚至不需要是开发人员也能理解它。格式通常是“给定 [状态的一些描述] 当 [执行某个动作] 那么 [某个验证]”。BDD 是一种黑盒方法(又称“基于规范的测试”)。
BDD 将使你的代码符合规范,但通常不会对每个细节进行彻底的测试。
在单元测试中(这是 TDD 和 BDD 的基础),好的做法是在一个测试中只测试一件事,但是当你的测试需要预定义的状态或依赖于某个依赖项的特定行为时,该依赖项必须在执行测试之前设置好。虽然可能可以通过调用代码中你实际测试的代码以外的其他部分(例如,在测试账户之间转账之前创建客户和账户)来实现所需状态,但这样做是不鼓励的。向不相关的功能添加依赖项会使测试难以调试;如果它失败了,那是因为依赖项还是你编写的代码?具有良好的可维护性和良好的性能是避免这种依赖关系的其他原因。
引入 fakes/mocks、可测试代码设计和依赖注入。这些的目的是能够模拟和控制你要测试的代码的依赖项,并且在不使你的测试代码过于臃肿的情况下完成。这些方法的共同点是它们提供了机制,你可以用替代实现替换依赖项,这些实现可以从你的测试中控制。这样,你的测试代码将以一个配置依赖项的序言开始,然后调用你希望测试的功能,最后验证结果。这就是我们所知的 Arrange-Act-Assert 模式。
由此,TDD、BDD、可测试代码设计和适当的 fakes 的组合将实现符合规范的坚实代码以及可靠且可维护的测试。对吗?
可惜不是。如果你不伪造依赖项,你会遇到各种麻烦,但如果你伪造它们,那么你的测试就依赖于你假定依赖项如何工作,而不是它们实际如何工作。最初这可能是同一回事,但如果你不持续更新测试以反映所有依赖项中的所有修改,那么一切都悬而未决,这几乎是不可能的,特别是当多个团队管理你的依赖项或你在分布式环境中工作时。
那么我在这里想说什么,TDD 和 BDD 不起作用吗?当然,但 TDD 只有在你的假设完整且随着时间的推移保持有效的情况下才有效,BDD 只有在规范已作为测试实现的情况下才能使你的代码符合规范。这个等式中缺少的是一个针对真实依赖项的可靠集成测试,它覆盖了 BDD 规范中遗漏的部分。当你的 TDD 或 BDD 测试代码上的集成测试失败时,你会得到一个提示,表明某个假设不再成立。
假设这个强大的集成测试可用,那么为什么还要使用 TDD 和 BDD 呢?这与时间和独立性有关:集成测试需要大量的可运行代码。有了 TDD 和 BDD,你可以在编码之前就开始,并且你的代码测试独立于系统其余部分的状态。
构建强大的集成测试
构建一个强大的集成测试可能是一项复杂的任务,因为在这里你通常会发现自己正在处理复杂的数据结构、状态,甚至可能是时间问题和异步行为。在我们深入研究所有这些之前,让我们回到最初的问题:你如何设计一个好的测试?
在本文的其余部分,我将描述无状态和同步场景中的技术。我将在我关于测试的下一篇文章中介绍有状态和异步系统。
举个例子,让我们使用我们都知道的东西:`DateTime` 和 `TimeSpan` 结构,并考虑如何根据 `DateTime` 和 `TimeSpan` 的属性来测试 `DateTime.Add(TimeSpan)` 方法。
我们可以从列出我们想要测试的内容开始:给日期加一年,Year 属性应该递增,而其他属性(Month、Day 等)应该保持不变。如果闰年的2月29日加一年,Month 和 Day 也应该改变,所以我们为此添加一个特殊情况,然后我们继续。
要理解这种方法,可以将程序想象成一组执行路径,其中每个输入都沿着自己独立的路径导向自己独立的输出。在测试中,我们选择一些输入,用这些输入调用程序,计算预期结果,并检查程序是否产生预期结果。
这种方法的问题是,路径的总数等于输入空间的大小,而且是巨大的。我们的选择没有结构,也没有完整性的概念,这使得我们无法对这种测试所覆盖和未覆盖的内容给出高层次的描述。这既不强大也不可靠!
为了获得完整性和覆盖率的概念,我们需要限制可能性的数量,而书中标准的技巧是使用分类来将本质上做相同事情的测试分组。
让我们从 DateTime.Year 开始。有效值范围是 1 到 9999,但我们需要测试每一年吗?当然不需要!范围内的最小值和最大值总是很好的候选,所以我们包含 1 和 9999。闰年是特殊的,所以我们至少需要一个闰年和一个非闰年。我们包含 2011 和 2012。世纪也是特殊的,1900 不是闰年而 2000 是,所以我们也包含这些。就这样。
因素 | 分类 | 基数 |
年 | 1, 1900, 2000, 2011, 2012, 9999 | 6 |
月 | 1, 2, 6, 7, 8, 12 | 6 |
日 | 1, 28, 29, 30, 31 | 5 |
小时 | 0, 12, 13 | 3 |
分钟 | 0, 30, 59 | 3 |
秒 | 0, 30, 59 | 3 |
毫秒 | 0, 500, 999 | 3 |
遵循同样的思路,我们可以识别剩余属性的值。
上面使用的技术的正式名称是“边界值分析”(BVA)和“等价类划分”(ECP)。BVA 适用于范围,而 ECP 适用于自然类别(如闰年和闰世纪)。BVA 中的标准做法是使用每个范围的最小值和最大值,但我喜欢也包含一个“正常”值(例如,范围内的中间值或随机值),以确保测试也覆盖了最常见的场景。
有了分类,我们就可以大致了解最多需要通过测试的 `DateTime` 实例数量。这是上述所有值的组合,共计 6*6*5*3*3*3*3 = 14,580 个不同的值。其中一些组合是无效的,但让我们暂时搁置这个问题,将 14,580 作为需要测试的 `DateTime` 实例数量的大致上限。虽然很多,但在情况好转之前,它必须先变得更糟!
因素 | 分类 | 基数 |
天 | 0, 1, 31, 365, 366, 367 | 6 |
小时 | 0, 11, 23 | 3 |
分钟 | 0, 29, 58 | 3 |
秒 | 0, 29, 58 | 3 |
毫秒 | 0, 499, 998 |
3 |
如果我们对 TimeSpan
属性进行类似的分类,我们可能会得到这些值。
请注意,思考方式略有不同,因为我们必须考虑将 TimeSpan
添加到 DateTime
的效果。对于秒,逻辑是“不改变秒的值”、“当秒处于中间时,不改变分钟的值”和“当秒处于中间时,改变分钟的值”。
我们必须使用的 TimeSpan
值总数为 6*3*3*3*3 = 486。如果我们将所有 TimeSpan
值与所有选定的 DateTime
值一起使用,我们将总共执行 7,085,880 次测试。
对所有这些组合进行编码是微不足道的,稍微加一点逻辑,我们就可以过滤掉无效的组合,而且由于 DateTime
操作速度很快,这个测试的实现可能会在几秒钟内完成。然而,如果我们将此应用于某个 Web 服务或某个在更大时间尺度上运行的复杂业务逻辑,我们就会遇到大麻烦。下面将详细介绍如何处理这个问题。
从这个例子中,**第一点启示**是使用 BVA 或 ECP 进行分类。它易于使用,并为你的测试提供了结构化方法。在测试中使用分类是高效测试的关键。从这个例子中,**第二点启示**是穷举测试是不可行的,即使你应用了分类,如果你尝试测试所有类别,你通常也会得到一个不可行的类别数量。然而,正如我将在下面展示的,你仍然可以做得很好。
目前我们面临以下未解决的问题:
- 如何将测试数量减少到更合理的范围?
- 如何处理无效组合?
- 如何验证?
减少测试数量
减少测试数量的一个显而易见的方法是考虑是否有必要将所有 TimeSpan
变体与所有 DateTime
变体一起尝试。沿着这个思路,你可能会选择几个有代表性的 DateTime
变体,将所有 TimeSpan
变体应用于它们,以及几个 TimeSpan
变体,将它们应用于所有剩余的 DateTime
变体。这种方法的问题是,它需要大量的工作才能找到好的代表,而且它只能将测试数量减少到任何因子最大变体数量的几倍(在这种情况下是 DateTime
变体的数量)。对于 DateTime.Add(TimeSpan)
测试,你仍然将面临超过 10,000 个测试。你的数据越复杂,你需要描述数据的因子越多,每个因子拥有的类别越多,情况就会呈指数级恶化。
让我们退一步,考虑一下我们正在做什么:我们正面临着 DateTime
类的一万到几百万个测试,但它内部可能有多少分支和多少真正的执行路径(即你实际编码的路径)?System.DateTime
的源代码有几百行代码,可能的执行路径数量和测试数量之间必须存在某种平衡,否则测试要么太大要么太小。
循着这个思路,我们之前所做的分类与代码内部的分支之间必定存在某种关系:如果所选值不影响执行流程,那么所有这些值所做的就是强制多次遍历同一路径。反之,未覆盖的路径意味着分类不完整。
假设 DateTime
类中有一段代码如下所示
if(IsLeapYear)
{
if(IsFebruary)
{
// Do something
}
else
{
// Do something else
}
}
else
{
// Do something third
}
如果我们的测试覆盖了 IsLeapYear
(真/假)和 IsFebruary
(真/假)的所有组合,那么我们就可以确定它遍历了所有路径。假设我们不知道代码是什么样子,但我们知道最大嵌套级别是两层,那么我们必须做什么才能覆盖所有路径呢?我们需要覆盖描述我们数据的任意两个因子的所有组合,这绝对不等同于所有因子的所有组合!
涵盖任意 t 个因子所有组合的因子组合列表的通用术语是强度为 t 的覆盖数组。覆盖数组是测试设计中极其强大的工具,因为它生成的测试套件规模小,而且覆盖数组的强度与未检测到的缺陷百分比之间存在经验联系。
根据**经验法则 #1**,使用强度为 t 的覆盖数组的测试设计所遗漏的缺陷百分比为 0.2^(t-1)。根据这条规则,强度为 2 的测试设计大约能发现 80% 的 bug,而强度为 3 的设计大约能发现 96% 的 bug,依此类推。这条经验法则基于几项经验研究(参见 http://csrc.nist.gov/groups/SNS/acts/documents/SP800-142-101006.pdf),这些研究报告了每个因子的检测率介于略低于 70% 到高于 95% 之间。
正如任何好的经验法则一样,免责声明应该比规则本身更大:使用 80% 只是因为容易记住(但使用 70% 并没有太大区别),该规则仅适用于你的分类与代码匹配,并且仅适用于你的代码具有正常分支行为(即,许多分支依赖于一两个变量,较少分支依赖于三个变量,等等)。
**经验法则 #2** 是关于覆盖数组的大小。没有精确的边界,但如果所有 k 个因子都具有相同的基数 v,那么大小的增长类似于 vtln(k)。对于不同的基数,你取描述数据的 t 个最大因子并将它们与 ln(k) 相乘,以获得一个大致的估计。
对于上面的 DateTime
示例,k 为 7,最大的因子是 Year、Month 和 Day,产生 6*6*ln(7) = 70 个组合;而对于 TimeSpan
,k 为 5,最大的因子是 Days 和 Hours,产生 6*3*ln(5) = 24,所以我们在这方面确实有所进展。
计算一个(短)覆盖数组远非易事,而找到最短的覆盖数组是 NP 完全问题。如果你决定使用覆盖数组,互联网上有一些工具可以做到这一点(我的工具也可以,包括处理约束),但如果你只需要快速而粗糙的东西,使用随机生成的数组也不错。假设你希望进行涉及强度为 2 的 CA 的测试,并且上面的经验法则 #2 告诉你你需要 100 个组合才能做到这一点,那么如果你简单地生成 100 个随机因子组合,那么你的测试将包含所有因子对的很高百分比(大约 80-90%)。如果你需要接近 100%,则创建更多测试(经验法则 #3 规定,因子 5 会让你非常接近)。这也适用于你需要更高强度的情况。
你可以在本文档的第 7 章中阅读更多关于覆盖数组与随机数组的信息:http://csrc.nist.gov/groups/SNS/acts/documents/SP800-142-101006.pdf
本文的**第三个要点**是,覆盖数组对于测试设计来说极其强大。它提供了对你的测试在检测 bug 方面的强大程度的经验评估,并且它提供了非常紧凑的测试设计(即,工作量更少!)。作为一种廉价的替代方案,你可以使用随机数组,并使用提供的经验法则来决定你需要多少个测试。
使用抽象分类消除无效组合
选择测试数据的分类方案类似于数据建模(例如,OO 或关系型):一旦你做得对,其他一切都会变得容易得多。
因素 | 分类 | 基数 |
年 | 最小, 最大值,普通年,普通世纪,闰年,闰世纪 | 6 |
月 | 一月,二月,六月,七月,八月,十二月 | 6 |
日 | 第一,倒数第二,最后 | 3 |
小时 | 最小,中,最大 | 3 |
分钟 | 最小,中,最大 | 3 |
秒 | 最小,中,最大 | 3 |
毫秒 | 最小,中,最大 | 3 |
我们在分类数据时给自己造成的问题之一,源于我们使用了具体分类而非抽象分类。使用抽象分类,我们得到诸如“FirstDayInMonth”、“SecondLastDayInMonth”和“LastDayInMonth”这样的值,这些值需要针对任何特定的年份和月份进行解析。抽象分类突出了我们试图实现语义,如果我们选择采用随机化,那么它在何处是合适的(例如,时间部分的“Mid”类别)就变得清晰了。
因素 | 分类 | 基数 |
天 | 零, 一,年,一年零一天 | 4 |
小时 | 换行,不换行 | 2 |
分钟 | 换行,不换行 | 2 |
秒 | 换行,不换行 | 2 |
毫秒 | 换行,不换行 |
2 |
对 TimeSpan
执行相同的操作,我们得到了右边的表格。同样,语义更加清晰,反映了我们想要实现的行为。另请注意,各类别的数量全面下降。
本文的**第四个要点**是,使用抽象分类并通过运行时解析获取具体值。这可以避免许多无效组合问题(但并非所有),一旦你开始处理动态数据,它会产生巨大的影响。你避免的麻烦很容易抵消运行时解析所需的额外工作。
有时,无论我们如何努力适应分类,无效组合都是不可避免的。如果你使用覆盖数组,你的生成器应该支持约束,因为从后过滤生成的覆盖数组中获取任何有用的无效组合是非常困难的。随机数组则不同,你可以简单地重试随机生成,直到获得与你的约束不冲突的组合。当然,你的约束越严格,获得成功组合所需的尝试次数就越多。
组合参数
更新分类后,DateTime
和 TimeSpan
变体的数量大致估计为 70 和 13。如果我们每个 TimeSpan
都与每个 DateTime
组合,我们将得到 910 个测试。这是正确的数字吗?我们能再降低一些吗?
让我们考虑一下我们的数据。我们肯定必须测试的一件事是闰年逻辑,所以我们至少需要考虑 Years、February、SecondLastDayInMonth
和 LastDayInMonth
的所有 DateTime
组合与每个可能的 TimeSpan.Day
值,同时我们可以将时间方面排除在外。现在,与其深入探讨一个非常巧妙的测试设计,不如我们简单地停留在观察上,即上述内容涉及四个可变因子,因此如果我们使用覆盖强度为 4 的覆盖数组,跨 DateTime
和 TimeSpan
的组合因子,那么我们可以确保测试覆盖了上述所有内容。
t | 测试数量 (经验法则 #2) | 测试数量 (已生成) | 检测率 (经验法则 #1) |
2 | 90 | 39 | 80% |
3 | 358 | 185 | 96% |
4 | 1073 | 705 | 99% |
5 | 3219 | 2546 | 99.8% |
6 | 9657 | 8087 | 99.97% |
Using rule of thumb #2 from above and assuming
使用上面的**经验法则 #1**,我们可以预期检测率大约在 1 - 0.2^3 ≈ 99% 左右,前提是**分类覆盖了功能**。有了代码覆盖工具,验证覆盖率以查看分类是否良好是直截了当的。
验证
到目前为止,我们只关注了测试的输入端,但没有验证的测试是无用的,那么我们如何进行验证呢?有两种极端方法可以做到这一点,介于两者之间还有一种方法。第一种极端方法是遍历每个组合,并找出预期的结果。这在手动和自动化测试中都很常见,但对于大型测试来说非常繁琐。另一种极端方法是构建你测试代码的参考实现,并验证你的代码和参考实现(**不是**你自己做的)产生相同的结果。
右图说明了介于两者之间的方法,即在抽象分类的基础上提供尽可能多的验证细节,然后添加足够的逻辑以产生预期结果。这种方法的基本思想是,与随机输入相比,输入端的分类提供了可用于简化验证的信息。
对于 DateTime.Add() 场景,验证部分是算术的,但我们可以通过确定何时发生溢出来辅助验证:有了抽象分类,很容易确定何时从毫秒到秒、从秒到分钟等等发生溢出,而有了溢出,计算毫秒、秒等的预期值又变得直截了当。
根据我的经验,输入和验证之间直接的依赖关系是常态而非例外,并且由此产生的验证可以出奇地简单,上面的例子属于比较复杂的一面。我用 C# 实现了 DateTime.Add() 的测试,并使用 QS 处理组合部分。测试的总代码量为 140 行(根据 VS2010 代码分析统计),QS 表达式为 55 行。随机数组方法(纯 C#)的代码量相似,为 205 行。代码和表达式适用于所有 CA 强度。
值得注意的是,这种验证方式与构建参考实现截然不同,这使得即使在验证自己的代码时也能安全使用。
本文的**第五个也是最后一个要点**是,抽象分类简化了生成用于验证的预期结果的任务,因为输入的抽象分类提供了关于预期结果的信息。
Using the Code
附带的代码包含两个 VS2010 项目和一个 QS 项目文件。项目“DateTimeTests.csproj”需要安装 QS 才能运行,而第二个项目“RandomArray.csproj”基本与第一个项目做相同的事情,但使用的是随机数组而不是覆盖数组。此项目不需要 QS。样本“AboutTheCode.pdf”中有一条注释,更详细地解释了代码。
结论
在本文中,我阐述了集成测试的必要性,并展示了(抽象)分类的概念如何提供一种可操作的(尽管基于经验的)覆盖率概念。我还展示了分类如何与以覆盖数组和随机数组形式出现的组合测试相契合,以及设计良好的抽象分类方案如何简化测试设计和验证。
本文描述的方法直接适用于无状态系统。要测试有状态系统,需要为状态组件添加单独的支持。实现这一点将是我下一篇文章的目标。
希望你喜欢这篇文章。