高级单元测试,第三部分 - 测试流程






4.89/5 (49投票s)
2003 年 9 月 29 日
14分钟阅读

665345

2621
扩展单元测试,以便可以测试整个流程。
目录
引言
在第三部分,我将介绍一些单元测试的扩展,我认为这些扩展使得单元测试对于我所做的类型的工作更有用。
新的用户界面
我通过添加选项卡页面来扩展了用户界面,分别用于通过、忽略和失败状态。测试夹具及其测试现在是按字母顺序排列的(但并非一定按字母顺序运行——排序是由 UI 完成的,而不是单元测试库)。最后,我添加了一些功能,这些功能也反映在 UI 中。
高级单元测试 - 测试流程
我编写的大部分代码(我想象其他人的代码也是如此)包括两种类型:
- 执行简单、特定任务的函数
- 以特定方式将函数粘合在一起的流程
很多时候,流程涉及用户的交互。一个非常简单的线性流程的例子是向导对话框,它以非常可预测和规范的方式引导用户完成配置。我在这些文章中一直使用的自动计费案例研究是一个潜在的非线性流程的例子。特别是当涉及大量用户交互时,软件必须变得更加健壮(和灵活),才能适应用户与程序交互的不同方式。Therac-25 中的编程错误导致辐射过量是一个很好的例子——界面涉及并发系统,如果操作员在输入错误后的 8 秒内纠正输入错误,系统就会失败。在这个特定的例子中:
普遍的共识是,加拿大原子能有限公司应负责。只有一个编写此系统代码的人,并且他主要负责所有测试。该机器仅进行了 2700 小时的使用测试,但对于控制如此关键的机器的代码,应该投入更多的时间进行测试。此外,Therac-25 是作为一个整体机器进行测试,而不是在各个模块中进行测试。在各个模块中进行测试本可以发现许多错误。此外,如果在第一次事故后 AECL 认为 Therac-25 存在问题,那么很可能其他 5 起事故以及可能导致的 3 起死亡都可以避免。1
另一方面,X-43A 事故,即一架高超音速吸气式飞行器在无人测试中失控,其原因不是未能测试各个模块,而是未能正确测试整个系统。
事故调查委员会发现,导致事故的主要因素是尾翼驱动系统的建模不准确、空气动力学的建模不准确以及建模参数的变化不足。只有当分析中包含了所有具有不确定性变化的建模不准确性时,才能重现飞行事故。2
因此,我们有了两个不同的例子,说明了为什么尽管进行了测试,但测试方式不当——人们丧生,纳税人的钱付诸东流。
现在,回到平凡的话题,在之前关于单元测试的两篇文章中,我
- 以测试优先的方式编写了一些单元测试(第一部分)
- 编写了存根代码以验证测试是否失败(第二部分)
- 修正了单元测试(第二部分)
- 实现了实际功能,使单元测试通过(第二部分)
但我的单元测试真的有那么好吗?整个流程(自动计费)包含许多不同的步骤,涉及许多组件,并且涉及大量用户交互。
这里有很多可能出错的地方,必须按特定顺序进行,并且容易因完成流程所需的时间而发生系统变化。考虑到从购买零件到收货可能需要数周时间,之后再收到发票还需要数周时间。或者,有时会发生发票在零件收到之前就寄到了!现在,我的案例研究确实是我在那里为其编写船厂管理软件的船厂的采购/收货/计费流程的一个简化版本,但即使是简化的版本,它也能很好地说明探索单元测试的目的。
减少不必要的重复
我的案例研究涉及很多重复。例如,测试 `ClosePO` 函数是否工作需要设置
- 两个工单
- 三个零件
- 一个供应商
- 一张发票
- 一项收费
- 以及圣诞铃铛(partridge in a pear tree)
但所有这些都已作为测试各个工单、零件、供应商、发票和收费的单元测试的一部分完成。为什么不将这些步骤合并成一个流程呢?
定义流程
流程是单元测试的有序序列。只要一个测试通过,下一个测试就会运行。这需要对 MUTE 进行几项修改:
- 必须将测试夹具指定为流程
- 测试本身必须指定其顺序
- 由于失败而未运行的测试应予以指示
- 正向和反向运行流程
顺序流程
顺序流程是指按照编写单元测试的程序员指定的顺序运行的流程。每个单元测试通常建立在前一个单元测试验证的信息之上。当一个单元测试失败时,序列中剩余的单元测试将被指定为“未运行”,因为运行它们没有意义。这通过为每个未运行的测试显示一个蓝色的圆圈来表示。例如:
未运行的测试列在“未运行”选项卡中
乱序流程
那么问题就来了,我们应该测试什么才能确保代码在用户或程序以意外方式(乱序)操作时能够很好地处理自身?显然,测试所有组合是不可接受的。我编写的采购订单顺序测试涉及 16 个步骤,测试 16 个步骤的所有组合是 16!,即 20,922,789,888,000(这几乎是 21 万亿个案例!)。
“乱序”是什么意思?这意味着一段代码在另一段代码之前运行。这显然减少了需要分析的组合数量,因为总组合包括许多代码仍然按顺序运行的组合,而我们对此不感兴趣,因为我们知道流程的“按顺序”部分已经通过了!只有一个运行所有代码乱序的组合,那就是流程反向运行时。因此,只需要进行两个测试——正向测试,其中流程按正向运行;反向测试,其中流程按反向运行。
好的,这不完全正确。例如,一段代码很容易依赖于两个或更多外部对象。仅按反向顺序测试只能捕获第一个依赖项。显然,要捕获第二个依赖项,至少必须运行一个前置项(按顺序)。此版本未处理此情况(是的,是的,我将在下一个版本中添加它,一旦我考虑清楚实现问题)。
并发流程
这是非常值得进一步扩展单元测试的,但我现在不会深入探讨相关问题。现在我们先保持简单!
新属性
为了支持所有这些,我们需要一些新属性。
ProcessTestAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple=false, Inherited=true)] public sealed class ProcessTestAttribute : Attribute { }
此属性附加到测试夹具(一个类)上,以指示测试运行器应按照程序员指定的顺序运行测试。例如:
[TestFixture] [ProcessTest] public class POSequenceTest { ... }
SequenceAttribute
[AttributeUsage(AttributeTargets.Method, AllowMultiple=false, Inherited=true)] public sealed class SequenceAttribute : Attribute { private int order; public int Order { get {return order;} } public SequenceAttribute(int i) { order=i; } }
此属性为流程测试夹具中的每个测试用例指定,编号从 1 到测试用例的数量。例如:
[Test, Sequence(1)] public void POConstructor() { po=new PurchaseOrder(); Assertion.Assert(po.Number=="", "Number not initialized."); Assertion.Assert(po.PartCount==0, "PartCount not initialized."); Assertion.Assert(po.ChargeCount==0, "ChargeCount not initialized."); Assertion.Assert(po.Invoice==null, "Invoice not initialized."); Assertion.Assert(po.Vendor==null, "Vendor not initialized."); } [Test, Sequence(2)] public void VendorConstructor() { vendor=new Vendor(); Assertion.Assert(vendor.Name=="", "Name is not an empty string."); Assertion.Assert(vendor.PartCount==0, "PartCount is not zero."); } [Test, Sequence(3)] public void PartConstructor() { part1=new Part(); Assertion.Assert(part1.VendorCost==0, "VendorCost is not zero."); Assertion.Assert(part1.Taxable==false, "Taxable is not false."); Assertion.Assert(part1.InternalCost==0, "InternalCost is not zero."); Assertion.Assert(part1.Markup==0, "Markup is not zero."); Assertion.Assert(part1.Number=="", "Number is not an empty string."); part2=new Part(); part3=new Part(); } ...
RequiresAttribute
世界并不完美,当我们反向运行单元测试时,我们不希望 *单元测试* 失败,我们希望看到 *被测试的代码* 是否失败。因此,在某些情况下,有必要执行一些流程中较早的代码,以确保依赖于此代码的单元测试不会中断。此属性处理此问题。例如:
[Test, Sequence(4), Requires("PartConstructor")] public void PartInitialization() { part1.Number="A"; part1.VendorCost=15; Assertion.Assert(part1.Number=="A", "Number did not get set."); Assertion.Assert(part1.VendorCost==15, "VendorCost did not get set."); part2.Number="B"; part2.VendorCost=20; part3.Number="C"; part3.VendorCost=25; }
要初始化一个零件,嗯,首先必须构造该零件!因此,此单元测试要求首先运行构造函数测试。
很容易陷入这样的想法,例如,关闭 PO 要求已分配零件和费用给 PO。 `Requires` 属性 *不* 应该这样使用,因为这只会确保流程按正向运行。相反,此属性应用于确保单元测试代码所需的参数已存在。我唯一需要 `Requires` 属性的时候是为了保证有一个对象存在,单元测试正要向其分配一个字面量。将上述示例与以下代码进行对比:
[Test, Sequence(15), Requires("POConstructor")] public void AddInvoiceToPO() { po.Invoice=invoice; Assertion.Assert(invoice.Number==po.Invoice.Number,
"Invoice not set correctly."); }
在这里,我们 *不* 要求构造 Invoice 对象。属性本身应该验证这一点。但是,我们 *确实* 要求 PO 对象先前已构造。一个简单的“左值”规则足以确定是否需要使用 `Requires` 属性——如果对象在等号的左边,则使用。如果在等号的右边,则不使用。
请注意,在 `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; } }
可以为同一个测试分配多个属性。例如:
[Test] [Sequence(16)] [Requires("POConstructor")] [Requires("WorkOrderConstructor")] public void ClosePO() { ... }
ReverseProcessExpectedExceptionAttribute
[AttributeUsage(AttributeTargets.Method, AllowMultiple=false, Inherited=true)] public sealed class ReverseProcessExpectedExceptionAttribute : Attribute { private Type expectedException; public Type ExceptionType { get {return expectedException;} } public ReverseProcessExpectedExceptionAttribute(Type exception) { expectedException=exception; } }
在常规单元测试中,`ExpectedException` 属性用于确保被测代码引发适当的异常,因为单元测试正在设置失败场景。流程测试设置为成功——换句话说,在正向运行流程时,不应该引发任何异常(引发异常的单个测试仍然是其他单元测试的一部分)。反向运行流程可能会导致已工作的代码失败,希望是由代码引发异常,而不是框架。为了测试这一点,添加了 `ReverseProcessExpectedException` 属性,以确保代码能够处理乱序流程。
案例研究
使用我在第一部分和第二部分中一直在开发的自动计费案例研究,我编写了一个流程测试,它经历了使 PO 可以关闭所需的所有步骤。将此代码与第二部分中编写的 `ClosePO` 单元测试进行比较:
[Test] [Sequence(16)] [Requires("POConstructor")] [Requires("WorkOrderConstructor")] public void ClosePO() { po.Close(); // one charge slip should be added to both work orders Assertion.Assert(wo1.ChargeSlipCount==1,
"First work order: ChargeSlipCount not 1."); Assertion.Assert(wo2.ChargeSlipCount==1,
"Second work order: ChargeSlipCount not 1."); ... }
请注意,所有设置工作都已完成。简单多了,不是吗?
正向处理
正向运行此流程,一切正常
反向处理
现在让我们看看当我反向运行流程时会发生什么
糟糕!显然,我的代码处理顺序错误的情况并不好!检查失败:
清楚地表明我根本没有很好地处理未初始化的对象。该修复了。
修复 ClosePO
如果发票不存在,则会引发异常
if (invoice==null) { throw(new InvalidInvoiceException()); }
这是一个很重要的问题,需要告知用户——没有发票的 PO 无法关闭!
修复 AddInvoiceToPO
这说明了测试属性赋值的有用性。单元测试本身可能会引发异常,因为属性赋值没有检查传递给它的对象是否有效!为了解决这个问题,对赋值进行了修改:
public Invoice Invoice { get {return invoice;} set { if (value==null) { throw(new InvalidInvoiceException()); } else if (value.Number=="") { throw(new UnassignedInvoiceException()); } // *** NO VENDOR TEST !!! *** if (value.Vendor.Name != vendor.Name) { throw(new DifferentVendorException()); } invoice=value; } }
测试属性
从技术上讲,getter 也应该在我们的单元测试中进行测试,这引出了一个问题:返回值对象本身是否应该测试值,还是请求值的对象。 |
“信息过载”的常见做法使这个问题变得复杂。也就是说,如果采购订单返回 NULL,则表示发票尚未设置。虽然这是简单的编码实践,但它不是一个好的实践。一个方法,如:
public bool InvoiceExists(void) {return value != null;}
是一个更好的解决方案。然后,getter 可以在调用者即将获取不当数据时引发异常。
修复 AddChargeToInvoice
这里存在同样的问题,并且很容易纠正:
public void Add(Charge c) { if (c==null) { throw(new InvalidChargeException()); } if (c.Description=="") { throw(new UnassignedChargeException()); } charges.Add(c); }
使用“has a”关系验证数据
这引出了另一个设计问题——如果 `Invoice` 类编写的方式仅仅返回一个调用者直接操作的费用集合,那么就无法捕获不良数据异常。 |
这指出了“has a”关系的优点——包装类可以执行数据验证,否则将不可能实现。
修复 InvoiceInitialization
这里有一个例子,其中单元测试引发异常,因为 `Invoice` 类没有测试有效数据。这很容易修复:
public Vendor Vendor { get {return vendor;} set { if (value==null) { throw(new InvalidVendorException()); } vendor=value; } }
修复 AddPartsToPO
添加了几个数据验证测试来解决这个问题:
public void Add(Part p, WorkOrder wo) { if (p==null) { throw(new InvalidPartException()); } if (wo==null) { throw(new InvalidWorkOrderException()); } if (p.Number=="") { throw(new UnassignedPartException()); } if (wo.Number=="") { throw(new UnassignedWorkOrderException()); } if (!vendor.Find(p)) { throw(new PartNotFromVendorException()); } parts.Add(p, wo); partsArray.Add(p); }
修复 AddVendorParts
更多相同的内容……
public void Add(Part p) { if (p==null) { throw(new InvalidPartException()); } if (p.Number=="") { throw(new UnassignedPartException()); } if (parts.Contains(p.Number)) { throw(new DuplicatePartException()); } parts.Add(p.Number, p); partsArray.Add(p); }
成功了!
现在反向运行流程工作正常,就其验证了所有不良数据并引发了适当的异常而言。
断言还是抛出,这是一个问题
单元测试确实将断言(或契约式编程)和抛出异常(让调用者处理错误)之间的区别摆在了最前面。这并不意味着契约式编程需要使用断言——而是意味着契约式编程 *不应该* 使用断言,而应该抛出异常。原因很简单——单元测试本身使用断言来验证数据,并期望在被测单元检测到故障时抛出异常。然后,单元测试验证异常是预期的还是非预期的。
抛出异常可以使代码更健壮。异常测试可以在生产代码中保留(并且应该!),以便更高级别的函数能够优雅地向用户报告问题并采取纠正措施。断言在生产代码中被删除时,只会导致程序崩溃或在发生意外情况(这不可避免地会发生)时出现错误操作。
因此,根据单元测试原则,断言将迅速走向灭绝。(有不同意见吗?)
我们学到了什么
- 单元测试本身也有 bug,因此需要进行测试。
- 编写“测试优先”代码比我想象的要好。
- 随着被测试函数在“对象链”中位置的升高,会编写大量冗余的设置代码。
- 单元测试并不能真正确保良好的编码和设计。坏代码和好代码一样容易通过单元测试。
- 单元测试测试的是单元,而不是流程。
- 测试属性赋值 *确实* 有用,特别是检查类是否验证了该值。
- 单元测试改变了使用断言转为抛出异常的范式(我猜这个说法会引起很多讨论)
关于代码的说明
目前的代码并不十分健壮。它没有验证:
- 流程序列从 1 开始
- 以 1 递增
- 没有重复或跳跃
- `Requires` 函数实际存在。
换句话说,这个东西真的需要编写一些单元测试!嗯,在下一个版本中,它会更可靠一些。
接下来...
好吧,第四部分不会讨论脚本。第四部分将介绍对单元测试的一些其他有用的补充。希望下一部分能够总结这些扩展(在我看来,这个问题本身就值得一篇文章),所以希望第五部分能涵盖脚本单元测试。
脚注
1 - http://neptune.netcomp.monash.edu.au/
cpe9001/assets/readings/www_uguelph_ca_~tgallagh_~tgallagh.html
2 - http://spaceflightnow.com/news/n0307/23x43a/
参考文献
在低级软件中检查高级协议:http://research.microsoft.com/~maf/talks/Berkeley-VAULT.ppt