QS 第 1 部分 - 单元和集成测试的新方法






4.93/5 (9投票s)
Quality Gate One Studio (QS) 是一个测试框架,它提出了一种单元和集成测试的新方法。
引言
单元测试框架已经存在了十年,并且提供了一种相当统一的测试表示方式,并具有许多共同的功能。这并不令人意外,因为这些框架的构建是为了支持相同的单元测试概念。现在介绍 Quality Gate One Studio (QS),它是在考虑了集成测试(包括性能测试和使用模型测试)的情况下构建的,旨在解决一个非常不同的问题。
背景
维基百科对单元测试和集成测试的定义如下:
单元测试是一种测试源代码的各个单元是否适合使用的方法。单元是应用程序中最小的可测试部分。在过程式编程中,单元可以是一个单独的函数或过程[...]理想情况下,每个测试用例都独立于其他测试用例:可以使用方法存根、模拟对象、伪造对象和测试驱动程序等替代项来帮助隔离模块进行测试。
集成测试的目的是验证对主要设计项的功能、性能和可靠性要求。这些“设计项”,即组合(或单元组),通过其接口进行测试,使用黑盒测试,通过适当的参数和数据输入来模拟成功和错误情况。.
虽然单元测试侧重于测试单个方法和类,并使用存根和模拟来隔离测试用例与环境以及彼此之间(通常采用白盒方法),但集成测试是一种黑盒方法,侧重于输入数据。维基百科文章从结构角度介绍了集成测试,但我认为同时也要加入状态的角度,以及这如何要求测试人员在对被测系统执行操作的顺序上要格外小心。
QS 方法
虽然 QS 使用与其他测试框架相同的概念,例如测试类、测试方法、设置/拆卸和代码中的断言,但它在其他方面与单元测试框架根本不同。
- 测试方法可以接受任意数量的参数,但与单元框架的数据驱动扩展不同,它不使用数据源来提供参数值。
- 测试方法可以将测试数据回传给 QS。
- 测试数据必须通过一个或多个分类属性进行分类(分类属性具有离散类型,实际上是布尔值或枚举)。
- 测试方法不再是测试的基本单元,而必须与一个关于其参数的表达式相关联。此表达式定义了一个或多个测试条件,这些条件大致相当于必须由测试方法参数满足的先决条件。这使得生成的“测试条件”成为测试的基本单元。
- QS 在管理测试数据(参数和输出)方面发挥着积极作用,它既存储输出,又确定可以将其用于哪些测试条件,并且,如果某个必须运行的测试条件缺少参数值,它会反向工作,找出必须执行哪些测试条件来获取所需的测试数据。
测试数据的管理和流程非常强大,但将在下一篇文章中讨论,现在将通过一个简单的示例来说明分类、表达式和条件,并以性能测量进行总结。
测试模数运算符
我决定从简单的事情开始,并选择了模数运算符 '%' 应用于 .NET 中的内置类型。为此,我们需要一些数字,因为 QS 坚持进行分类,所以我们需要提供一些(等价)划分,用于不同类型的可用数字范围。我选择了以下方法:
- Zero
- 一
- 一个小的质数 (13)
MaxValue
(例如,short.MaxValue
,int.MaxValue
等)- 小于
MaxValue
的最大质数 - 上述数值的正数和负数变体
我相信这种分类是有争议的:我没有包含 MinValue
,2 可能也很特殊,对于大于 32 位的整数类型,在 32 位系统上进行全面测试至少还应考虑 32 位和 64 位周围的边界。即便如此,此测试的目的也不是查找 .NET 中的缺陷,而是说明该方法,上述选择大大简化了问题。
我们如何为 QS 表示这一点,以便它能够理解?我们只需创建一个 enum
来表示上述分类,并创建一个带有两个属性的类,一个用于分类,一个用于符号(布尔值)。
enum MagnitudeEnum { Zero, One, SmallPrime, LargePrime, Max}
public class MInteger
{
public MagnitudeEnum Magnitude { get; set; }
public bool Positive { get; set; }
}
我将该类命名为 MInteger
,因为 QS 使用一个称为“元模型”的概念,并且约定是在类名前加一个大写 M。MInteger
是一个(非常简单的)整数类型的元模型,因为它陈述了值的通用性质,而不是具体的值。QS 对元模型有强大的支持,但这将在后续文章中讨论。
我们面临的下一个挑战是如何将 MInteger
转换为一个值,以及如何将 MInteger
对象导入 QS 机制,以便将它们作为参数传递给我们将要编写的测试方法。
我通过向 MInteger
添加属性解决了第一个问题,例如 AsByte
, AsShort
, AsInt
等。这些属性会检查 MInteger
的幅度和符号,以动态生成值。我本可以使用转换,但我希望非常明确我想要的值,并且不希望出现任何隐式转换带来的意外。
下一部分很简单:QS 支持一个名为 SettableAttribute
的属性,当该属性应用于类时,QS 将检查该类是否具有默认构造函数和适当属性的 setter。当需要类实例时,QS 将使用默认构造函数创建它,并设置所需的属性值。MInteger
的最终代码如下所示:
[Settable, Serializable]
public class MInteger
{
public MagnitudeEnum Magnitude { get; set; }
public bool Positive { get; set; }
public byte AsByte { get { ... } }
public byte AsShort { get { ... } }
public byte AsUShort { get { ... } }
public byte AsInt { get { ... } }
public byte AsUInt { get { ... } }
public byte AsLong { get { ... } }
public byte AsULong { get { ... } }
}
我还使该类可序列化。这有利于 QS,并使其能够通过执行期间所有参数值的副本进行全面跟踪,从而能够重新运行并简化调试。QS 还支持 ICloneable
接口以实现更快速或更高级的克隆,但使类可序列化是最简单的方法,而且我在这里不需要任何快速或高级的功能。
范围蔓延 - 性能测量
QS 内置了高精度性能计时器,用于计时每次测试条件的执行。我对不同类型的性能特征感到好奇,所以当我在做这件事时,我决定准备我的测试以进行性能测试。这包括输入一个额外的参数来控制 for
循环的迭代次数,并确保要测量的区域紧密围绕我想要测量的区域。
有了这个设置,以下是测试 byte
类型模数的代码:
[TestClass]
public class ModTests
{
[Settable, Serializable]
public class IterationControl
{
public enum IterationEnum { Once = 1, K = 1000, K50 = 50000 }
public IterationEnum Iterations { get; set; }
}
[TestMethod]
public static void ModByte(MInteger a, MInteger b, IterationControl ic)
{
byte va = a.AsByte;
byte vb = b.AsByte;
try
{
int c = 0;
int n = (int)ic.Iterations;
TestContext.Current.Timer.Start();
for (int i = 0; i != n; ++i)
{
c = va % vb;
}
TestContext.Current.Timer.Stop();
if (a.Magnitude == b.Magnitude ||
a.Magnitude == MagnitudeEnum.Zero ||
b.Magnitude == MagnitudeEnum.One)
{
Assert.AreEqual(0, c, "{0} % {1} = {2}, expected 0", va, vb, c);
}
else
{
Assert.IsTrue(c > 0, "{0} % {1} = {2}, expected > 0", va, vb, c);
}
}
catch (DivideByZeroException)
{
Assert.AreEqual(MagnitudeEnum.Zero, b.Magnitude,
"Got unexpected divide by zero exception");
}
}
}
备注
每当 QS 调用测试方法时,它都会提供一个线程本地测试上下文,并且可以通过该上下文控制性能计时器。我只需在循环前重新启动此计时器,然后在之后停止它,以获得最精确的计时。
与单元测试和数据驱动测试的另一个不同之处在于,断言取决于参数。在某些情况下,例如上面的情况,这会使测试方法的验证部分复杂化,尽管听起来可能有些奇怪,但在集成场景中,它实际上可以简化验证。
异常始终被 QS 视为故障,并且没有 ExpectedExceptionAttribute
或类似的功能来指示预期异常。这是有道理的,因为验证取决于参数,正如你所见,我必须在 catch
块中根据 b
的大小进行断言。
最后,我决定不验证余数的实际值,除了它是否为正数、负数或零。之所以选择这个捷径,是因为要验证模数,我需要找到一个数学关系,我可以确定它不涉及与模数本身相同的实现,或者我必须自己实现一个独立的模数。最后,我决定相信 .NET 能够正确地进行算术运算 ;-)。
表达测试条件
设置好参数和测试用例后,我们就可以开始使用 QS 来实现它了。我将跳过设置项目以编译程序集的实际操作,直接跳到生成 ModByte
测试条件的表达式:
a.(Positive.{True} * Magnitude.{*})
* b.(Positive.{True} * Magnitude.{*})
* ic.Iterations.{Once}
意思是:对于参数 a
,将 Positive
设置为 true
,并尝试 Magnitude
的所有可用值,对于每个 a
的选择,设置 b
.Positive
为 true
,并尝试 b
的所有幅值。对于所有这些组合,只考虑 ic.Iterations
的值 Once
。
这扩展为以下先决条件列表:
a.[Positive, Magnitude.LargePrime], b.[Positive, Magnitude.LargePrime],
ic.[Iterations.Once]
a.[Positive, Magnitude.LargePrime], b.[Positive, Magnitude.Max], ic.[Iterations.Once]
a.[Positive, Magnitude.LargePrime], b.[Positive, Magnitude.One], ic.[Iterations.Once]
a.[Positive, Magnitude.LargePrime], b.[Positive, Magnitude.SmallPrime],
ic.[Iterations.Once]
a.[Positive, Magnitude.LargePrime], b.[Positive, Magnitude.Zero], ic.[Iterations.Once]
a.[Positive, Magnitude.Max], b.[Positive, Magnitude.LargePrime], ic.[Iterations.Once]
a.[Positive, Magnitude.Max], b.[Positive, Magnitude.Max], ic.[Iterations.Once]
a.[Positive, Magnitude.Max], b.[Positive, Magnitude.One], ic.[Iterations.Once]
a.[Positive, Magnitude.Max], b.[Positive, Magnitude.SmallPrime], ic.[Iterations.Once]
a.[Positive, Magnitude.Max], b.[Positive, Magnitude.Zero], ic.[Iterations.Once]
a.[Positive, Magnitude.One], b.[Positive, Magnitude.LargePrime], ic.[Iterations.Once]
a.[Positive, Magnitude.One], b.[Positive, Magnitude.Max], ic.[Iterations.Once]
a.[Positive, Magnitude.One], b.[Positive, Magnitude.One], ic.[Iterations.Once]
a.[Positive, Magnitude.One], b.[Positive, Magnitude.SmallPrime], ic.[Iterations.Once]
a.[Positive, Magnitude.One], b.[Positive, Magnitude.Zero], ic.[Iterations.Once]
a.[Positive, Magnitude.SmallPrime], b.[Positive, Magnitude.LargePrime],
ic.[Iterations.Once]
a.[Positive, Magnitude.SmallPrime], b.[Positive, Magnitude.Max], ic.[Iterations.Once]
a.[Positive, Magnitude.SmallPrime], b.[Positive, Magnitude.One], ic.[Iterations.Once]
a.[Positive, Magnitude.SmallPrime], b.[Positive, Magnitude.SmallPrime],
ic.[Iterations.Once]
a.[Positive, Magnitude.SmallPrime], b.[Positive, Magnitude.Zero], ic.[Iterations.Once]
a.[Positive, Magnitude.Zero], b.[Positive, Magnitude.LargePrime], ic.[Iterations.Once]
a.[Positive, Magnitude.Zero], b.[Positive, Magnitude.Max], ic.[Iterations.Once]
a.[Positive, Magnitude.Zero], b.[Positive, Magnitude.One], ic.[Iterations.Once]
a.[Positive, Magnitude.Zero], b.[Positive, Magnitude.Zero], ic.[Iterations.Once]
在项目中,我为所有内置类型(byte
, short
, ushort
, int
, uint
, long
, ulong
, float
, double
, 和 decimal
)创建了类似的表达式,并将生成的条件组织成用于功能测试、1000 次迭代的性能测试和 50,000 次迭代的性能测试的测试集。
有符号类型的函数测试表达式如下:
a.(Positive.{*} * Magnitude.{*} - [!Positive, Magnitude.Zero])
* b.(Positive.{*} * Magnitude.{*} - [!Positive, Magnitude.Zero])
* ic.Iterations.{Once}
(这里的技巧是修剪生成的“负零”测试,它们会与“正零”重复。)
有符号类型的性能测试表达式如下:
a.(Positive.{*} * Magnitude.{*} - [!Positive, Magnitude.Zero])
* b.(Positive.{*} * Magnitude.{!Zero})
* ic.Iterations.{K}
它与有符号类型类似,除了零被排除在分母之外,并且迭代次数不同。
模数性能测试
现在,代码已经准备好了循环和控制迭代次数的参数,并且 QS 项目已经设置了单独的测试集,用于功能测试,迭代 1000 次的性能测试,以及迭代 50,000 次的另一个性能测试。
我为什么选择这些迭代次数?首先,循环必须足够长才能以合理的精度进行测量。QS 使用 Windows 中的高分辨率性能计数器,在考虑了调用和计算结果所需的时间后,它的精度可以远低于一微秒。为了安全起见,循环不应少于一微秒。下一个问题是每次测量最多应花费多长时间?要回答这个问题,我们必须考虑上下文切换和分配的时间片大小。如果循环运行的时间超过一个时间片,上下文切换将影响所有样本,并将整个计时分布推向更大的值;但如果循环运行的时间比时间片短,一些循环将完成而不会遇到上下文切换,而那些不可避免会遇到的上下文切换将显示为方差。Microsoft 表示时间片的大小取决于操作系统和处理器,因此没有确切的答案,但这些东西通常在毫秒级别上运行,所以我假设循环不应运行超过 1 毫秒才能获得可靠的数据。
结果表明,在我当前的系统上,最快操作的 1000 次迭代大约需要 5us,这对于使用更快系统的用户来说还有余量。另一方面,50,000 次迭代需要 150us,远低于 1ms 的阈值。
但是,我们为什么要考虑更大的迭代次数呢?如果我们要做多线程测试,我们应该保持在测试运行器的容量之内。在我的系统(双核 32 位系统)上,QS 使用两个线程可以处理大约 50,000 次测试/秒,如果我们为多线程测试坚持使用 1000 次迭代(使用两个线程和两个核心最多可提供 40,000 次测试/秒),QS 本身将花费大量的处理器时间,并使结果变得无用。
考虑到这一点,我们可以开始进行一些测量:对于第一个测试,我选择了包含所有数据类型中 343 个不同测试的测试集“性能测试 (K1)”。此测试集不涉及异常(DivideByZero
)。我将执行设置为随机化,停止标准设置为 9 秒,禁用节流,并将线程数设置为 1
。然后只需按几次“运行”按钮即可获得重要的统计数据。
注意:QS 的免费版本会在 10 秒后以 10 次测试/秒的速度激活节流,所以我改为了多次运行 9 秒。如果您自己运行此测试,请记住在开始性能测试之前清除历史记录,否则旧的记录将成为最终结果的一部分。
完成后,我得到了这个报告:
首先要注意的是,QS 中的所有统计数据都基于百分位数,而不是平均值/方差。从统计学角度来看,这是有道理的,因为通常情况下,我们不能期望性能计时有任何特定的分布,所以我们无法可靠地解释平均值和方差。
仔细查看图表显示,EmptyLoop
的运行时间为 2.57us,ModUint
的运行时间为 4.77us(最低百分位数)。当迭代次数为 1000 时,uint
类型模数运算的计时为 2.2ns,而 byte
、short
、ushort
和 int
的最佳执行时间为 2.56ns。
这就是全部真相吗?不完全是!图表显示执行时间存在相当大的方差,如果我们放大到各个测试条件,就会出现不同的情况:
结果表明,当分母为 1 且分子不为 1 时,可以获得最佳执行时间。从那里开始,它会变慢,直到分子是大的质数或 uint.MaxValue
,并且分母是小的质数。在这种情况下,最佳执行时间已增加到 4.8ns。结果仍然存在方差,但这次方差发生在多次相同的操作数测量之间,因此我将其归因于“噪声”(上下文切换和缓存效应)。
感到惊讶吗?嗯,我当时是。我知道算术函数的软件算法的执行时间取决于操作数(通常是结果中设置的位数和位置),但我没有意识到它对硅片上的实现有如此大的影响。
为了结束这一部分,让我们看看所有类型的计时,这次按中位数排序:
与之前一样,最快的类型是 uint
,其次是 byte
、short
、ushort
和 int
。在我的 32 位系统上,float
和 double
的性能相同,而 64 位类型 long
和 ulong
的性能比 32 位类型慢近一个数量级。decimal
类型自成一派,基于此和其他经验,除非我必须使用它(至少在 32 位机器上),否则我不会使用它。
核心是否共享算术运算?
为了回答这个问题,我只是用 50,000 次迭代在一到两个线程中运行了测试。首先,一个线程,结果按中位数排序:
两个线程的结果非常相似:
总的来说,这两个图在 5% 和 95% 百分位数上看起来非常相似,但 long
和 ulong
的中位数有所波动。这实际上是我运行测试方式的一种人为痕迹;我选择了随机执行,这意味着在执行过程中会随机选择条件。这导致在运行期间,某些条件比其他条件执行的次数更多,并且由于类型执行时间取决于条件(即相应的参数值),因此它会影响响应时间分布。解决方案当然是查看各个条件。
我决定更仔细地研究 ulong
类型上模数的最快和最慢变体,并将其显示在趋势图上。第一次测量是一个线程,第二次测量是两个线程,并且统计数据基于第一个图上的 2x66 次测量,以及第二个图上的 2x63 次测量。
在第一个图中,从一个线程移动到两个线程时方差减小了,但由于我们处理的是相对较少的 66 次测量样本,并且样本遇到上下文切换的几率很低(由于执行时间短),方差较低可能是因为第二次测试遇到的上下文切换比第一次测试少。在 50% 和 75% 百分位数上,执行时间在单线程和双线程之间没有变化,这表明两个核心不共享资源。
第二个图显示从单线程到双线程的方差增加,并且这种趋势对于所有针对 long
类型的长时间运行的条件都是一致的。由于执行时间较长,遇到上下文切换的几率会增加,因此方差增加是预期的。总的来说,增长趋势太小,不足以表明两个核心之间存在资源共享。
综上所述,这些测量结果证实了两个核心是独立工作的,并且不共享资源来计算模数。
Using the Code
附带的 zip 压缩包包含一个 VS 2008 项目和一个 QS 的项目文件 (.qgproj)。VS 项目没什么可说的。代码非常直接,遵循了上面的描述。有一点需要注意:我不得不复制粘贴测试方法,为每种类型创建一个。一般来说,这是不好的风格 - 即使在测试中 - 但我无法绕过它。任何建议都非常受欢迎。
要运行 QS 项目,您需要下载并安装 Quality Gate One Studio (QS),然后只需双击 .qgproj 文件即可打开 QS 项目。您需要查阅文档了解如何使用该工具。
关注点
QS 如何影响我完成预期测试的能力?
首先,由于 QS 强制要求对输入数据进行分类,我被引导(可以说被迫)通过必须选择测试数据的等价划分来从更高层次考虑我的测试数据。虽然我的选择显然有争议,但它给了我一组清晰的、可以单独监控的测试条件。如果我只对功能测试感兴趣,我可以用一个预定义值的列表和几个嵌套循环来实现相同的结果。我实际上没有尝试过,但我的感觉是,对于这个测试,我最终会得到大致相同的代码量,但它会与我为 QS 编写的代码截然不同。
QS 使测试的性能部分变得容易。虽然我仍然需要仔细考虑我的设置,特别是如何在不同情况下使用多少次迭代,但它免费为我提供了计时、迭代、线程、统计数据和图表。由于 QS 的免费版本在 10 秒后会激活节流,我决定将性能测试运行 9 秒,这迫使我使用随机执行模式。虽然随机模式在另一个领域(场景)有其应用,但它不适合我在这里进行的低级计时实验。我本可以使用顺序执行模式,该模式更适合我的实验,但它需要我每 10 秒重置一次节流。所以,让我们面对现实吧,我在执行方面很懒惰,不得不在分析 2 核结果上付出更多努力。
关于计时结果,结论是(在 32 位系统上),uint
和 int
是性能方面的首选类型。像 byte
或 short
这样的小类型有一个小缺点,因为在进行算术运算之前,类型会被扩展到 32 位,计时结果证实了这一点。float
和 double
的性能相同,并且通常比 long
类型具有更好的性能和更低的计时方差,long
类型比 int
慢大约 10 倍。decimal
类型又比 long
慢大约 10 倍,这表明当类型大小加倍时,执行时间会增加 10 倍(int
是 32 位,long
是 64 位,decimal
是 128 位)。对于模数或其他操作或在其他平台上是否也是如此,尚未进行测试。
在下一篇文章中,我将研究 QS 的数据管理/数据流能力,以及当进入集成测试时如何使用它。虽然这可能会改变,但我目前的计划是使用一个模拟的电子邮件服务器来演示 QS 如何处理数据流和时间依赖性。其他建议也欢迎。
历史
- 2010 年 11 月 6 日:初版