用更少的代码进行更多的单元测试 - 组合单元测试
组合单元测试
引言
我喜欢单元测试。它们很棒,快速,可靠,独立,易于阅读和编写。如果你的单元测试不是这样,你还有改进的空间。但本文并非关于单元测试及其优点,而是关于组合单元测试以及如何用更少的精力覆盖更多内容。
背景
在本文中,我假设您熟悉 C# 中的 lambda 和匿名类型概念,并且对可枚举和枚举器有信心。我还假设您在单元测试方面有一定的经验。我提到 NUnit 作为测试框架只是为了描述单元测试的一些技术方面,并且它已被用来执行一些内部断言,这些断言可以轻松地被几乎任何其他断言框架(如 Shouldly 或 FluentAssertions)替换。
组合测试很重要
那么什么是组合测试?组合测试是指为测试参数提供的所有单个数据项的所有可能组合提供测试用例的测试。换句话说,这些测试旨在验证结果,而不考虑给定数据的组合。这些测试经常用于确保提供的参数与行为之间没有相关性,并且行为是一致的。它们非常有用,例如,用于验证字符串(null
/空/空白/包含人类可读文本的字符串/带有尾随空格的字符串等)周围没有特定逻辑,或者,例如,用于验证序列化往返 - 当你的数据传输对象(DTO)应该能够完美地序列化和反序列化,来回进行,特别是当你使用自定义序列化引擎时,它需要辅助属性(例如,protobuf-net 带有 ProtoMember
属性),而这些属性很容易遗漏某些内容。
组合测试很痛苦
现在想象一下,你想测试这样的构造函数
public void SomeConstructor(string stringArg, long longArg, double doubleArg)
- 对于
string
参数,我至少会测试:null
、空字符串、空白、非空白; - 对于
long
参数,我至少会测试:long.MinValue
、long.MaxValue
、-1L
、0L
、1L
; - 对于 double 参数,我至少会测试:
double.MinValue
、double.MaxValue
、-1.0d
、0.0d
、1.0d
;
这给了我 (4 个 string
组合) x (5 个 long
组合) x (5 个 double
组合) = 100 个组合。如果我们使用 for
/foreach
循环来运行它们,甚至并行运行它们,这并不是一个很重的负担,但是如果将此引入,例如 NUnit 的 TestCaseSource
,它将生成 100 个测试用例,每个测试用例都会带来显著的额外管理开销。
- NUnit 将不得不生成所有这些测试用例,它们将被包装在
TestCaseData
中; - 对于每个测试,它都必须调用
SetUp
和TearDown
; - 每个测试将按顺序执行;
随着潜在值数量的增加,情况会迅速变得更糟 - 根据实际实验,100K 个测试用例会让 NUnit "准备"几分钟。
因此,我开始寻找方法来以一种原始、简洁的方式描述相同的测试用例,并使这些测试用例几乎瞬时创建。
理论
我确定了以下目标
- 组合单元测试应该是与测试框架无关的。这意味着我不应该扩展任何特定的框架功能,例如,实现自定义属性/接口。
- 组合单元测试应该是自描述的。这意味着我应该自然地读取组合并看到测试本身,这样我就可以快速理解测试在做什么以及考虑了哪些类型的测试用例作为输入数据。
- 它应该是一个低样板的。最大限度地减少启动该功能所需的麻烦。描述部分不应比测试部分长。
一旦我将自己从实现中抽离出来,并从“客户端”的角度对待我的代码,我就提出了几个可以工作的语法构造。经过进一步思考,我决定采用如下的一种。
Combinations
.Compose(x => new
{
Greeting = x.Only("Hello", "Howdy", "GDay"),
Participant = x.Only("John", "James", "Bob")
})
.RunInParallel(test =>
{
Console.WriteLine("{0}, {1}", test.Greeting, test.Participant);
});
这对我来说似乎很合乎逻辑,有两个明显分开的部分
声明部分由 Compose
方法公开。此方法期望一个 lambda,该 lambda 将描述带有为每个参数建议的值的类型安全的测试用例。类型安全性在重构期间非常重要,因为它有助于确保声明部分和执行部分之间的类型一致性。所以我这样理解:“Compose
测试用例,作为 Greeting
参数的组合,该参数只接受“Hello
”、“Howdy
”和“GDay
”,以及 Participant
参数,该参数只接受“John
”、“James
”和“Bob
”。
测试部分由 RunInParallel
方法公开。此方法期望一个 lambda,该 lambda 将描述测试本身。lambda 提供 test
参数,该参数可以访问特定的测试用例数据。对于给定的示例,test.Greeting
的值应该是“Hello
”、“Howdy
”或“GDay
”,而 test.Participant
应该是“John
”、“James
”或“Bob
”。
声明的开销最小,唯一的问题是如何实现它。
实现
Compose
方法提供了一个特定类型的实体,该实体用于描述序列。我称这个实体为 Combinator
- 一个拥有声明序列列表和填充这些序列的方法的实体。Combinator
类型被设置为 public
以便最终用户可以访问,但它被声明为 sealed
,因为我不期望任何继承,并且它的构造函数被设置为 internal
,假设客户端不应该显式创建此类型的实例。序列 列表是 private
的,序列本身是一种可枚举序列。
public sealed class Combinator
{
private readonly List<IEnumerable> sequences = new List<IEnumerable>();
internal Combinator()
{
}
/* Other stuff */
}
根据上面的示例,我期望 Combinator
将包含 Only
方法,该方法接受一个值列表,这些值代表特定类型的序列。此方法的返回类型用于定义匿名类中的属性类型,所以显然该方法应该是泛型的。但是返回类型呢?它应该返回什么值?它实际上并不重要,因为该值永远不会被使用。真正重要的是将给定的项目列表添加为序列到私有集合中。我还决定调整方法签名,要求至少有一个项目,以及任意数量的额外项目,使用 param
s - 这将防止调用时没有项目(空序列)。
public T Only<T>(T atLeastOne, params T[] orAnyNumberOfOther)
{
sequences.Add(new[] { atLeastOne }.Concat(orAnyNumberOfOther).ToArray());
//// Returning the stub.
return default(T);
}
总而言之,Combinator
是由 Compose
方法创建并传递给其 lambda 的,在 lambda 中,它用于声明和保留序列,并识别具有所有属性类型正确的测试用例匿名类型。此处及以下的假设是序列声明的顺序和匿名类型的属性顺序匹配。
此时,我们将序列视为“扁平”的可枚举,但是要执行组合测试,我们将不得不生成另一个“扁平”序列,其中包含所有可能的组合。这就是为什么 Combinator
还公开了一个名为 Yield
的内部方法来完成此目的。
internal IEnumerable<T> Yield<T>()
{
/**/
}
此方法的完整实现可在本文附带的源代码中找到,这里放不下,但关键亮点将是
T
是一个匿名类型。它与Compose
方法生成的匿名类型完全相同。实际上,匿名类型是编译器生成的类型,所以编译器将在编译时生成特定的“未命名”类型,该类型将有一个构造函数,接受匿名类型声明的所有属性的所有值,按照声明的顺序。考虑到这一点,使用activator
创建匿名类型的实例非常容易。- 此方法返回
IEnumerable<T>
,因此我们可以利用yield
关键字按需生成实例。这一事实减少了准备开销,特别是在并行运行场景中。 - 原始序列至少被枚举一次,因此将它们存储为数组或集合(而不是其他按需评估的可枚举)很重要。
- 实现严重利用了给定可枚举的枚举器实例,因为它们提供了快速重置它们或访问当前迭代值的能力。
一旦完成了 Yield
部分,我们就有了一个最终的序列可枚举,生活变得显著容易,因为我们只需要迭代序列并使用给定的组合调用测试方法。这可以通过纯 foreach
循环顺序执行,或者使用例如 Parallel.ForEach
并行执行。
可扩展性
提供的结构非常开放,可以进行扩展。作为扩展的一个例子,让我们考虑 string
的示例,它在组合测试中通常非常重复。在验证构造函数或方法参数的特定参数(类型为 string
)时,开发人员倾向于使用诸如 string.IsNullOrEmpty(...)
和 string.IsNullOrWhiteSpace(...)
之类的辅助方法,这些方法通常也适合使用组合测试进行验证。我将提供以下示例实现供参考。
public string NullEmptyAndWhiteSpace()
{
sequences.Add(new object[] { default(string), string.Empty, " ", "\t" });
//// Returning the stub.
return default(string);
}
序列表示为 null
,空字符串,单空格字符串和包含制表符的字符串。根据我的实践经验,制表符的情况通常会被忽略,但仍需考虑。与其他序列声明方法一样,返回值无关紧要,但其类型则不然,这就是为什么返回类型是 string
并返回 default(string)
。为了进一步练习,尝试为 double
添加序列,并且不要忘记包含极端情况,例如 double.NaN
、double.PositiveInfinity
和 double.Epsilon
。
历史
- 版本 1.0 - 初次发布
- 版本 1.1 - 添加了源代码仓库 URL