高级单元测试,第四部分 - 夹具设置/拆卸、测试重复和性能测试






4.70/5 (40投票s)
2003年10月9日
21分钟阅读

228670

2320
本文扩展了单元测试框架,增加了夹具设置/拆卸功能和性能(时间与内存)测量/测试。
目录
引言
本系列的第四部分介绍了基本单元测试应用程序的最后一组扩展。这些扩展包括
- 夹具设置和拆卸属性
- 处理时间测量
- 测试重复
- 内存利用率测量
我参与过许多与硬件交互的应用程序,以及需要优化分析算法的应用程序,例如网络跟踪和实时图像处理。我认为这让我对单元测试有了与主流讨论不同的视角。当然,关于这样的功能是否应该成为单元测试属性套件的一部分,或者作为断言在测试代码中实现,存在争议。我主张将此功能作为单元测试属性的一部分的理由如下:
- 它提供了一种一致的机制
- 功能已为程序员编写好
- 它更容易记录单元测试的意图
- 它使测试分类更容易
缺点
执行时间和内存测试存在一些缺点。
一般问题
执行和内存测试是围绕单元测试的委托调用 (utd
) 实现的。
startMem=GC.GetTotalMemory(true); startTime=HiResTimer.Ticks; try { utd(); } catch(Exception e) { throw(e); } finally { stopTime=HiResTimer.Ticks; endMem=GC.GetTotalMemory(true); executionTime=(stopTime==startTime ? 1 : stopTime-startTime); }
这意味着实际测量的内容不仅包括正在测试的函数,还包括调用该函数的包装器。显然,这会产生意想不到的副作用,特别是当单元测试本身执行不属于实际测试代码的耗内存和/或耗时代码时。处理这种情况的最佳方法是实现两阶段测试序列——第一阶段进行设置,第二阶段调用被测方法。
即时编译器 / 程序集
激活 JIT 编译器的代码(例如,根据我所读到的,对于泛型,JIT 编译器会在泛型类型首次构造时将泛型 IL 替换为特定类型并编译为本地代码)会导致首次性能下降。所有 Microsoft 中间语言 (MSIL) 代码都如此——它们在首次加载时由 JIT 编译器转换为本地处理器指令。您可以使用 MUTE 看到这种性能差异——第一次运行测试时,性能明显慢于后续运行。
其他执行时间问题
显然,其他工作线程、其他应用程序和服务、网络性能、服务器性能等问题都会影响执行时间。大多数执行时间问题都通过指定测试重复次数来解决。测试运行器会丢弃最佳和最差时间样本,并报告平均时间。
垃圾回收
实施垃圾回收 (GC) 的环境几乎不可能准确跟踪函数在线程内分配的内存。调用 GC.Collect()
方法或其他函数也不能保证正确的值,因为垃圾回收在内部 CLR 线程上运行。您可以在 MUTE 中看到这种情况。如果您更改代码以在委托调用之前调用 GC.Collect()
... GC.Collect(); startMem=GC.GetTotalMemory(true); startTime=HiResTimer.Ticks; try { utd(); } ...
测试的性能会显著下降,并且与已进行的分配数量成比例。
集合,如ArrayList和Hashtable
随着集合的增长,维护列表元素所需的空间(换句话说,其*容量*)会增加。使用 Clear() 函数时,列表中包含的对象会被解除引用(为了论证起见,我们假设没有其他对象引用列表中的对象),GC 可以回收它们。然而,集合使用的内部缓冲区*并未被回收*。对于 `ArrayList` 集合,您可以通过 `Capacity` 属性手动回收缓冲区空间,并将其设置为零,这将内部缓冲区设置为默认数组大小(即 16)。`Hashtable` 集合(以及任何实现 `IDictionary` 的集合)没有相应的函数。
例如
public class CaseStudyMemoryTests { Vendor vendor; [Test] public void AllocationTest() { vendor=new Vendor(); for (int i=0; i<100000; i++) { Part p=new Part(); p.Number=i.ToString(); vendor.Add(p); } vendor.Clear(); } }
上面的代码分配了大约 10MB 的内存。当调用 `vendor.Clear` 时,仍然剩下大约 2.7MB 的已分配内存!`Vendor` 类维护一个 `ArrayList` 和一个 `Hashtable`(有点像 `SortedList` 的工作方式)。当 `ArrayList Capacity` 属性重置为 `parts.Capacity=0` 时,已分配内存进一步减少到 2.2MB。不幸的是,没有办法回收 Hashtable 使用的缓冲区。
我个人认为,这指出了 .NET 框架中集合实现的一个问题。应该能够回收缓冲区。假设供应商对象管理的下一个零件列表包含 10 个零件(可能是因为零件列表已被过滤)。如果第一个列表包含 100,000 个零件,那么维护 10 个零件的集合就浪费了 2MB。现在,你们都说“哇哦”,因为你们的系统有 1GB 内存。嗯,我来自内存昂贵的时代,无论是物理上的金钱还是使用上的。我们的应用程序之所以如此臃肿,原因之一就是像 `Hashtable` 这样草率的实现。我说,是时候编写我自己的集合类了。
扩展 MUTE
如本节所示,扩展单元测试框架非常简单。
步骤 1:定义一个属性
在 UnitTest 程序集,Attributes.cs 文件中定义新属性。如果该属性(例如“CodeProject”属性)是一个类属性,则定义如下:
[AttributeUsage(AttributeTargets.Class, AllowMultiple=false, Inherited=true)] public sealed class CodeProjectAttribute : Attribute { }
反之,如果它是一个与方法关联的属性(例如“Bob”属性),则定义如下:
[AttributeUsage(AttributeTargets.Method, AllowMultiple=false, Inherited=true)] public sealed class FixtureSetUpAttribute : Attribute { }
在上述两种情况下,以上示例都假设一个给定类或方法只关联一个属性实例。对于没有任何参数的属性,情况显然如此。如果您的属性带有参数,那么您可能需要将 `AllowMultiple` 设置为 true,就像 Requires 属性一样。此属性也演示了如何管理属性参数:
[AttributeUsage(AttributeTargets.Method, AllowMultiple=true, Inherited=true)] public sealed class RequiresAttribute : Attribute { private string priorTestMethod; public string PriorTestMethod { get {return priorTestMethod;} } public RequiresAttribute(string methodName) { priorTestMethod=methodName; } }
步骤 2:定义属性功能
在 UTCore 程序集,TestUnitAttribute.cs 文件中定义属性的功能。所有属性都派生自 `TestUnitAttribute` 类。(是的,我认为这应该重构,将实现拆分为基类和一些接口。)例如,“CodeProject”类属性将创建为:
public class CodeProjectAttribute : TestUnitAttribute { public override void SelfRegister(TestFixture tf) { // ... do something here ... } }
SelfRegister
方法为属性提供了在以下一个或多个对象中设置状态的途径:
- 测试夹具
- 该类
- 该方法
显然,如果属性与类关联,则方法项无效。以下两条规则适用(也表明了一些重构可以使事情更容易使用):
- 由于测试夹具和类之间存在一对一的关联,我通常将类属性选项放在
TestFixture
对象中。 - 由于方法属性和方法之间存在多对一的关联,我将方法属性选项放在
MethodItem
对象中。
这是一种处理新属性的廉价而粗糙的方式,应该进行重构,以便使用更多的消息传递机制。这样,类和方法属性就可以独立管理,并且可以使用消息传递来提供自定义扩展,而无需更改核心夹具和方法类。有人愿意接受吗?
访问属性参数
在框架调用 SelfRegister
之前,TestUnitAttribute
类已初始化了属性对象。要访问属性,请将其转换为相应的 UnitTest
程序集属性并提取所需信息。例如:
public class RepeatAttribute : TestUnitAttribute { public override void SelfRegister(TestFixture tf) { mi.RepeatCount=(attr as UnitTest.RepeatAttribute).RepeatCount; mi.RepeatDelay=(attr as UnitTest.RepeatAttribute).RepeatDelay; } }
步骤 3:实现运行器、类和方法扩展
正如我刚刚所说,这是一种廉价且粗糙的做事方式。但嘿,这不是 XP 方法吗?
运行器扩展
这一步只在您想要改变夹具内部测试运行方式时才需要。例如,在上一篇文章中,我讨论了将测试按顺序作为测试过程的一部分运行。然而,通常测试以不可预测但一致的顺序运行。运行器扩展可能会真正随机化测试顺序。其他扩展可能支持多线程测试,其中多个测试夹具同时运行以测试信号量、互斥体等。总之……
在 TestFixture.cs 中,有一个对测试运行器工厂的调用,它根据夹具(类)属性创建适当的测试运行器。
public void RunTests(TestNotificationDelegate testNotificationEvent,
TestIterationDelegate testIterationEvent) { notificationEvent=testNotificationEvent; iterationEvent=testIterationEvent; TestFixtureRunner tfr=CreateTestFixtureRunner(); tfr.RunTests(); }
如有必要,修改 CreateTestFixtureRunner 工厂。当前实现支持运行一个过程(一系列测试)和独立运行测试。这是一个最基本的实现。
// test fixture runner factory public TestFixtureRunner CreateTestFixtureRunner() { if (isProcessTest) { return new TestFixtureRunProcess(this); } return new TestFixtureRunIndividual(this); }
所有自定义测试运行器必须派生自 TestFixtureRunner
并实现两个函数(嗯,你是否在这里闻到了接口的味道???)
public abstract void RunTests(); public abstract bool ExceptionConfirmed(Exception e, TestAttribute ta);
TestFixtureRunner
类实现了 RunTest
方法,该方法应始终用于运行实际的单元测试。它需要一个包含单元测试的类实例,通过调用以下方式构造:
object instance=tf.SetUpClass();
以及被测方法的 TestAttribute
。这是与方法关联的 [Test] 属性,无论该方法可能还关联了任何其他属性。
遍历测试夹具中的所有测试是直接的,并且至少需要:
foreach (TestAttribute ta in tf.TestList) { RunTest(instance, ta); }
类扩展
可以在 TestFixture.cs 文件中的 SetUpClass
和 TearDownClass
方法中添加扩展类(并因此扩展测试夹具)的属性。
public object SetUpClass() { instance=tfa.CreateClass(); fsua.Invoke(instance); return instance; } public void TearDownClass() { ftda.Invoke(instance); }
目前,这些只是简单地实例化类并调用夹具设置和拆卸方法(如果已定义)。同样,此代码应重构为使用消息传递或事件机制,以便轻松扩展夹具属性。
方法扩展
方法属性指定的附加功能要么在 MethodItem.cs 文件中处理,要么作为新测试运行器的一部分。如果您直接扩展方法调用,这将在 Invoke
方法中完成。但请注意,用于*测试*特定条件(如内存使用、处理时间、使用的句柄等)的属性实际上是在 TestFixtureRunner.cs 文件中的 RunTest
方法中实现的。测试应设置方法的 TestAttribute
状态和结果消息,以便 GUI 可以正确显示结果。
ta.State=TestAttribute.TestState.Fail;
ta.Result="... your message ...";
夹具设置和拆卸
TestSetUp
和 TestTearDown
属性指定在夹具中每个测试运行之前和之后运行的函数。相反,FixtureSetUp
和 FixtureTearDown
属性指定在夹具中*所有*测试运行之前和之后运行的函数。
为什么使用夹具设置?
有几个原因:
- 测试夹具包含一套测试,每个测试都对一组通用数据进行操作;
- 数据集非常庞大,并且可能需要很长时间才能为每个测试加载;
- 与硬件交互时,很可能存在一个与正在执行的测试无关的设置和拆卸过程;
- 任何时候需要与测试功能无关的通用设置和/或拆卸时;
- 启动支持测试所需的单独进程
一个简单的案例研究
我为一位客户开发的一个应用程序涉及使用 TCP/IP 与不同的硬件模块进行接口。网络上通常有 30 到 60 个这样的模块,每个模块都配置成执行不同的任务——处理纸币识别器、解锁旋转门和门、报告警报、提供打卡服务、报告系统状态等。我没有把所有这些硬件都放在家里,而是编写了一个模拟器,它可以作为单独的应用程序运行,无论是本地还是在另一台计算机上。验证应用程序和模块之间数据包 I/O 的单元测试需要启动模拟器并在测试完成后将其关闭。这在测试夹具的设置和拆卸函数中很容易处理,与在夹具中的每个测试中都这样做相比,节省了大量时间。
为什么不直接使用测试夹具的类构造函数?
这种做法有一些优点,但也存在几个问题:
- 它打破了使用属性指定由测试运行器在特定时间运行的特殊代码的模型。
- 它打破了测试设置和拆卸函数之间的对称性。
- 在 C# 中,没有相应的析构函数,因此无法在析构函数中进行测试夹具的拆卸。
一个例子:测量处理时间
性能测量说明了此功能的有用性。对于此示例,我将扩展我在前几篇文章中开发的案例研究。
[FixtureSetUp] public void TestFixtureSetup() { vendor=new Vendor(); for (int i=0; i<100000; i++) { Part p=new Part(); p.Number=i.ToString(); vendor.Add(p); } }
以上函数创建 100,000 个零件并将它们与供应商关联。测试夹具的其余部分测量以下性能:
- 按零件对象随机访问
[Test] public void RandomAccessTestByPart() { int n=rnd.Next(0, 100000); Part p=new Part(); p.Number=n.ToString(); bool found=vendor.Contains(p); Assertion.Assert(found==true, "Expected to find the part!"); }
- 按索引随机访问
[Test] public void RandomAccessTestByIndex() { int n=rnd.Next(0, 100000); Part p=vendor.Parts[n]; Assertion.Assert(p.Number==n.ToString(),
"Parts not in the same order as when added!"); }
- 按零件编号随机访问
[Test] public void RandomAccessTestByNumber() { int n=rnd.Next(0, 100000); bool found=vendor.Contains(n.ToString()); Assertion.Assert(found==true, "Expected to find the part!"); }
上述示例表明,除了测试设置和拆卸功能之外,夹具设置和拆卸功能也有其用武之地。
处理时间
测量函数的处理时间并非易事。首先,不能使用 DateTime.Now.Ticks
属性,因为它没有必要的精度。尽管 TimeSpan.TicksPerSecond
报告的间隔为 100ns,但这并非 DateTime.Now.Ticks
属性的实际精度。一个简单的测试可以说明这一点。使用该类:
public class HiResTimer { public static long Ticks { get {return DateTime.Now.Ticks;} } public static long TicksPerSecond { get {return TimeSpan.TicksPerSecond;} } }和功能
public void Resolution() { long n1=HiResTimer.Ticks; long n2; long n3; // sync while (n1 == (n2=HiResTimer.Ticks)) {} // wait while (n2 == (n3=HiResTimer.Ticks)) {} long q=n3-n2; // q==156250
结果是 q=156250,分辨率为 15.625 毫秒。相反,必须使用 QueryPerformanceCounter
和 QueryPerformanceFrequency
函数。
public class HiResTimer { [DllImport("Kernel32.dll")] private static extern bool
QueryPerformanceCounter(out long lpPerformanceCount); [DllImport("Kernel32.dll")] private static extern bool
QueryPerformanceFrequency(out long lpFrequency); public static long Ticks { get { long t; QueryPerformanceCounter(out t); return t; } } public static long TicksPerSecond { get { long freq; QueryPerformanceFrequency(out freq); return freq; } } }
这导致大约 569 纳秒的分辨率(至少在我的电脑上是这样)。好多了!
测试处理时间
验证处理时间是可疑的,因为处理时间因机器、其正在执行的任务以及单元测试正在交互的其他技术而异。然而,这并不意味着在适当使用时,测试函数的处理时间没有价值。有几个适当的应用浮现在脑海中,例如:
- 检测效率极低的算法;
- 协助检测服务质量 (QoS) 问题;
- 在受控环境中达到一些基准性能。
我处理过所有这些情况下的单元测试——用于卫星交换环的网络分析应用程序,由于卫星互联网模拟器中的雨衰导致的比特率下降,以及将状态信息实时更新到数据库。虽然算法的性能因机器而异,但拥有一个最小的“每秒操作数”标准非常有用,尤其是在调整一些低级代码时,这些代码最终会对算法的性能产生重大影响。
MinOperationsPerSecond
属性可以应用于任何单元测试以验证性能。例如:
[Test] [MinOperationsPerSecond(150000)] public void RandomAccessTestByPart() { int n=rnd.Next(0, 100000); Part p=new Part(); p.Number=n.ToString(); bool found=vendor.Contains(p); Assertion.Assert(found==true, "Expected to find the part!"); }
上述单元测试验证随机访问测试的执行速率至少为每秒 150,000 次操作。
测试重复
性能测试无疑可以受益于重复测试,以平均化多任务操作系统中时间测量的变化。Repeat
属性通知测试运行器,测试应该重复指定的次数,可以选择在每次重复之间设置延迟。例如:
[Test] [MinOperationsPerSecond(150000)] [Repeat(100)] public void RandomAccessTestByNumber() { int n=rnd.Next(0, 100000); bool found=vendor.Contains(n.ToString()); Assertion.Assert(found==true, "Expected to find the part!"); }
上面的代码将运行 100 次。从测试结果来看
很明显,该实现存在严重问题(在这种情况下,我实现了一个非常笨拙的函数,它遍历零件集合中的每个元素直到找到匹配项)。
还有哪些用途?
正如我在介绍中所述,我做了很多与硬件相关的工作,除了反复重复测试之外,没有其他方法可以测试硬件。我遇到的问题多得我不想记住,我的代码之所以出现问题,是因为每千次中就有一次硬件故障报告错误值。其他用途也比比皆是——没有什么比物理拔掉网线或拔掉服务器电源插头更能了解客户端软件如何处理故障了。监控网络负载是另一个需要重复的应用。如果停止以僵化的“测试一次分析”思维方式思考,用途就会层出不穷。
内存利用率
正如我在引言中讨论的那样,在垃圾回收环境中几乎不可能跟踪内存分配。GC 环境在监控内存时也会产生困境,此时对问题进行一些分析有助于我们选择合适的解决方案。
新建/删除 vs. 垃圾回收
在经典的内存管理方案中,程序员需要释放分配的内存,内存只有两种状态:
- 已分配
- 未分配
在使用垃圾回收的系统中,程序员不需要释放分配。内存仍然有两种状态:
- 已引用
- 未引用
但这些状态与已分配/未分配状态不同。就物理内存而言,GC 系统有*三种*状态:
- 已分配 (已引用)
- 已分配 (未引用)
- 未分配 (未引用)
正是“已分配但未引用”的状态导致了在确定任何给定时间有多少内存“正在使用”时出现如此多的困惑。这部分内存已分配,但正在等待 GC 回收。这部分内存是计入未分配总数还是计入已分配总数?根据内存监控的目的,答案是不同的。内存测试是否应该检查以下内容:
- 函数运行后,没有意外的内存仍被引用?
- 函数使用了合理的内存量?
- 函数是否正确清理,以便最有效地利用内存(这涉及我之前提到的集合缓冲区等问题)?
- 函数是否正确处置了非托管对象?
试图在 GC 系统中获取已分配内存的真实计数的问题在于,测试本身会干扰我们试图测试的对象!就像薛定谔的猫,在盒子打开和查看之前,既不死也不活一样,已分配但未引用的内存也处于这种既非已分配也非未分配的准状态。一旦我们调用 GC.GetTotalMemory(true);
,所有未引用的内存(理想情况下)都会被回收,并且我们得到一个真实(同样是理想情况下)的可用内存计数(所以,我想猫在盒子打开后总是死的)。因此,在一个理想世界中,这段代码:
... startMem=GC.GetTotalMemory(true); startTime=HiResTimer.Ticks; try { utd(); } catch(Exception e) { throw(e); } finally { stopTime=HiResTimer.Ticks; endMem=GC.GetTotalMemory(true); ...
将测量在 utd()
委托调用之后仍有多少内存被引用。然而,这并没有告诉我们函数运行时内存利用率的任何信息,包括它分配、引用和随后解除引用的内存量。此外,世界并不理想。GC 不会回收所有未引用的内存,而是启动一个单独的线程。函数 GetTotalMemory(true)
仅仅等待很短的时间。所以:
- 我们无法保证获得准确的计数
- 被测单元的时间会被打乱,因为 GC 正在后台运行
另一个问题是,GC 仅报告其管理的内存。GC 对位图、COM 对象等非托管内存一无所知。在我关于 IDispose
的文章中,我使用一个 3MB 的 JPG 图像演示了这一点。GC 报告零内存利用率,而对象却被引用着!更糟糕的是,如果不能正确处置对象,物理内存将继续被占用,直到耗尽所有内存,GC 才最终开始回收。然而,位图之类的东西本身就是一个有趣的问题。它们是一种“准托管”资源,因为包装类实现了 IDispose
接口,因此当托管资源被回收时,非托管资源会被清理。托管/非托管资源之间的这种绑定使得资源管理问题更加令人困惑。
很明显,使用 GC 来测试内存分配是毫无意义的。它不准确,影响其他性能测量,并且不完整。
作为文档的单元测试
请记住,单元测试的一部分目的是指导程序员正确实现被测功能。关于内存利用率,单元测试需要考虑 GC 的性质和被测对象的性质。真正需要确定的是实现是否:
- 在资源完全在 .NET 框架外部(例如在 COM 对象中)分配的情况下,需要支持手动清理;
- 在手动清理托管资源可以提高整体性能的情况下,需要支持“定向”清理;
- 可以完全依赖 GC 最终进行清理。
这个标准让我们对单元测试中内存测试的目的有了更清晰的认识。
手动清理
对于完全在 .NET 框架领域之外分配的资源,需要手动清理。这通常意味着 COM 对象或需要应用程序明确释放这些资源的其他第三方程序。由于 GC 函数在跟踪这类内存方面毫无用处,我们必须依赖系统诊断来告诉我们这些函数使用了多少内存。因为这些资源完全不受 GC 管理,所以没有实现 IDispose
的绑定托管资源,因此程序员必须将资源包装在一个类中,该类要么实现 IDispose
,要么提供其他机制来释放资源。单元测试应包含确保应用程序与第三方功能接口以回收资源所需的任何代码。
定向清理
定向清理处理非托管资源已被 .NET 框架(或应用程序)中的类包装,从而成为“托管”的情况。位图或其他 GDI 资源就是这类示例。通常需要手动引导托管资源的非托管部分的回收,以便内存和/或句柄不会无限制地继续分配。等待所有物理内存和所有虚拟内存都被消耗,GC 才开始回收资源,这会导致您的应用程序乃至整个系统性能极差。单元测试需要以“文档”此实现需求的方式编写。
重要的是要认识到,对于定向清理单元测试,*我们不希望 GC 运行*。如果 GC 开始回收内存,那么被封装在托管对象中的非托管资源将被释放。相反,单元测试应该确保定向清理*实现*是正确的。
自动清理
在这种情况下,应用程序将依赖 GC 在决定开始回收时执行所有清理。单元测试不需要测量内存或资源利用率。这种“不测试”方法只应在资源完全由 GC 管理时采用——即没有接口或包装非托管对象的对象。我能想到的唯一例外与管理大型集合有关。在这种情况下,对集合进行定向清理将提高内存利用率。然而,由于 .NET 集合类不能以手动方式完全回收内存,目前这有点毫无意义。希望当泛型实现并且我们可以迁移到 STL 容器方法时,.NET 集合类可以被抛弃。
内存测试
如果您接受我上面描述的三种情况(手动、定向和自动),那么应该很清楚 GC 提供的内存函数不合适,因为我们真正感兴趣的只是跟踪非托管资源,无论它们是否被托管对象包装。为此,我们只需要使用一个简单的辅助类来监视进程内存:
using System.Diagnostics; public class ProcessMemory { public static int WorkingSet { get {return Process.GetCurrentProcess().WorkingSet;} } }
它返回我们进程物理分配的内存(我们将忽略虚拟内存分配)。MaxKMemory
用于指定函数在进程堆(而不是 GC 池)上允许分配的最大内存量,而不会失败。例如:
[Test] [MaxKMemory(1000)] public void UnmanagedTest() { ClassBeingTested cbt=new ClassBeingTested(); cbt.LoadImage("fish.jpg"); cbt.Dispose(); }
上述代码验证,在图像加载并处置后,分配的内存少于 1,000K(1MB)。这个测试实际上在做的是:
- 告诉程序员该类需要实现 IDipose
- 验证 IDispose 是否正常工作
(为保持下载文件大小小,我*未*包含 fish.jpg 文件)。
结论
我完全同意,对于大多数应用程序来说,其中一些测试的实用性是值得怀疑的。然而,在我自己的小世界里,我发现它们非常有帮助。这里的真正意义在于,单元测试的目的是为程序员提供一套工具,他们可以从中选择,以尽可能最好地自动化不同的测试需求。我相信 MUTE 做到了这一点,并为程序员提供了良好的框架(尽管需要一些重构!)以根据自己的需求继续扩展它。