关于测试的第二部分 - 测试有状态系统






4.93/5 (6投票s)
如何管理状态并模拟被测系统的行为。
引言
在本文中,我将探讨如何使用基于模型的技术对有状态的系统进行集成测试。我们将从一个简单的状态机模型开始,然后过渡到一种地点-转移(place-transition)模型,利用集成场景的优势,结合系列文章第一篇中的组合测试设计,最后加入依赖性分析并支持异步环境。通过这些,您可以获得一种集成测试方法,将设置前提条件的繁琐工作交给计算机来完成。此外,您还可以获得一个易于扩展为负载仿真的框架,并且通过迭代测试可以提高测试覆盖率。
背景
在我之前关于组合测试的文章中,我探讨了如何通过 BVA 和 ECP 进行分类来抽象数据,并讨论了覆盖数组的概念如何减少必要的测试量并提供基于经验的覆盖率估计。这对于无状态函数来说效果很好,但正如您可能已经意识到的,现实世界通常是有状态的。
状态机
在本文中,我们将使用一个 Bug 跟踪系统中的 Bug 报告作为工作示例。Bug 报告的特点是什么?它包含一些详细信息,如受影响的系统、组件、版本、平台等,然后有一个状态字段。通常情况下,根据您的设置,它会有诸如 New、Verified、Fixed、Confirmed、Rejected 和 Closed 等状态。状态(或者我以后称之为“状态”)决定了您可以并且必须对 Bug 报告执行什么操作:状态之间的切换存在限制,并且您在其他状态下可能需要执行某些操作(例如添加评论)。自然的模型是这样的状态机:
很明显,我们至少需要为上面的每一条弧线添加一个测试。每个弧线一个测试,我们就能实现 0-switch 覆盖,这只在您确定历史记录没有影响时才足够:考虑从 Confirmed 到 Rejected 的弧线。如果只有对该转换的一个测试,那么就无法知道 Bug 报告是如何进入“Confirmed”状态的,它可以从 New、Fixed 或 Verified 状态进入。如果历史记录很重要,则需要更多测试来实现更高的 switch 覆盖。1-switch 覆盖考虑了一个历史状态更改,2-switch 考虑了两个更改,依此类推。
暂时假设我们实现了一个简单的状态机,并将所有弧线连接到一个会更改状态并进行验证的方法,那么我们就实现了一个简单的基于模型的测试。每当机器到达一个有多个出站弧的状态时,机器就必须选择要遵循的弧线。如果它使用图着色等确定性方法,我们可以确保它会终止,但它只能保证 0-switch 覆盖。另一方面,如果它只是随机路径,我们不能确定它是否会终止,但它运行的时间越长,测试的路径越多,switch 覆盖率就会越高。这就是所谓的马尔可夫链统计测试(MCST)。
缺点是状态机对复杂性(即表示的因子数量或不同的状态变量)的响应能力较差。状态的数量随着因子的数量呈指数增长,而弧的数量随着状态数量的平方增长,因此状态机模型会迅速变得糟糕。
P/T 网
如果我们想处理真实场景,就需要更好的方法,所以让我们修改一下图:
我们这里看到的是一个地点-转移(Place/Transition)网,也称为 Petri 网。它看起来像带有标签的状态机,但非常不同。状态机关注单个对象的状态及其如何状态之间转换,而 P/T 网中的节点是多个对象的占位符。标签(称为转换)是只要输入节点包含对象就可以触发的动作。转换可以消耗任意数量的对象,从任意数量的地点,并可以类似地将任意数量的对象输出到其他地点。
在上图中,我添加了一个不需要任何输入的“Create”转换,最初这是唯一可以触发的转换。一旦触发,“New”地点将出现一个对象,它可以被“Reject”、“Confirm”或“Fix”转换消耗,而一旦其中任何一个被触发,其他转换就会被启用,依此类推。
像这样的 P/T 网非常适合仿真:在每次迭代中,确定启用的转换,随机选择一个并触发它,然后重复。它还可以并发工作,因此如果您实现这样的模拟器,并稍微注意性能和线程,您就拥有了一个负载模拟器。
P/T 网可以轻松处理需要多个对象的转换,例如将 Bug 报告关联起来。
只要“Confirmed”地点有一个对象,并且“Fixed”地点有另一个对象,“Associate”转换就会被启用并准备触发。
很酷,对吧?您可以使用这样的模拟器进行强大的测试,而且随机元素具有一个很好的特性,即模拟运行时间越长,它覆盖的序列就越多。
带依赖解析的 P/T 网
只要仿真完全是随机的,您就不能确定所有转换(即所有测试)都会被触发,但这并不难解决。假设您需要确保触发针对“Confirmed” Bug 的“Fix”。为此,我们需要一个“Confirmed” Bug,我们可以通过确认一个“New” Bug,或者重新打开一个“Fixed”或“Verified” Bug 来获得。您应该选择哪种方法?基于优先级的依赖性分析可以做到这一点:为所有转换分配优先级,并将优先级 0 分配给不消耗任何令牌的动作,例如“Create”转换。优先级 1 是由优先级 0 中动作的输出启用的所有转换,依此类推。
如果您需要执行一个未启用的优先级 3 转换,那么您需要找到一个优先级 2 或更低的转换来产生一个启用对象,依此类推。递归地执行此操作很容易,如果转换需要多个对象,只需一次一个对象地重复此过程。此外,如果您无法通过这种方式为转换分配优先级,则意味着没有办法产生所需的输入,并且该转换是死状态。有了这个,就可以轻松确保所有活动的转换至少触发一次。
向 P/T 网添加依赖解析提供了一些很棒的机会:您可以选择一个转换(即一个测试),让模拟器自动设置所有先决条件——无需再进行繁琐的 Arrange 代码!通过一些记账,您还可以扩展模拟器进行基于频率的负载仿真,您只需指定少数选定转换的百分比(例如 50% Confirm、20% Fix、30% reject),然后让模拟器找出中间值以实现指定的负载场景。这比设置详细流程的常规方法要容易得多,也真实得多。就像状态机变体一样,这也可以看作是马尔可夫链统计测试(MCST)的一个实例。
Using the Code
到目前为止,一切都很简单,构建一个简单的随机 P/T 网模拟器并不需要多少工作。我在本文附加的代码中提供了一个 P/T 网模拟器,您可以将其作为自己工作的基础。它加载 XML 中指定的网络,并包含我上面讨论的优先级分析、依赖解析以及对 MCST/基于频率场景的支持。代码通过构建一个“Fixture”来工作,该 Fixture 包含到目前为止已获取的所有方法调用的参数。当所有参数都绑定后,Fixture 就可以执行了。您需要添加错误处理,以防您的(测试)方法抛出异常,并且对于重负载,您需要使其多线程。这应该很简单。
完成?
就这样?基于模型的测试已经解释清楚,我们就可以开始测试任何迎面而来的东西了?对于简单场景和小网络来说,是的,但一旦复杂性增加,例如网络规模、非确定性、异步行为和数据之间的依赖性,维护显式模型就会变得繁琐。
接下来是概念上简单但需要大量工作才能转化为有用应用程序的部分。这就是我在 Quality Gate One Studio 产品中所做的,本文的其余部分在某种程度上是针对该工具的。
自动生成 P/T 网
就这样?基于模型的测试已经解释清楚,我们就可以开始测试任何迎面而来的东西了?对于简单场景和小网络来说,是的,但一旦复杂性增加,例如网络规模、非确定性、数据相互依赖性和异步行为,维护显式模型就会变得繁琐。
接下来是概念上简单但需要大量工作才能转化为有用应用程序的部分。这就是我在 Quality Gate One Studio 产品中所做的,本文的其余部分在某种程度上是针对该工具的。
让我们回顾一下 P/T 网的组成部分:
- 地点
- 转换
- 输入弧线(从地点指向转换的弧线)
- 输出弧线(从转换指向地点的弧线)
然后回顾一下我系列文章中的第一篇文章,它指导您完成分析测试设计和组合测试。本文的核心是将分类应用于测试方法的输入参数。结果是,任何给定测试的每个参数都属于一个类别,或者用 P/T 网的术语来说,属于一个地点。有了这个,我们可以确定 P/T 网的四个组成部分中的三个:
- 地点对应于单个测试中参数的类别。
- 转换对应于一个测试。也就是说,测试方法与特定类别参数之间的绑定。
- 输入弧线对应于特定类别(一个地点)与测试方法的特定参数的绑定。
这就剩下输出弧线了,但如果可以检查一个对象(例如通过反射)并确定它属于哪个类别/地点(如果有的话),并且可以检查从转换出来的对象是什么,那么输出弧线就可以通过执行启用的转换来观察。
- 输出弧线对应于转换输出结束的地点的。
发现过程如下:

最初没有已知的输出弧线,但优先级 0 的转换是启用的。触发这些转换提供了足够的信息来识别优先级 1:
这个过程一直持续到所有转换都被排名或识别为死状态。
使用组合测试设计定义网络
在前一节中,我解释了如何通过按优先级顺序触发转换来发现输出弧线,但这只节省了不到一半的网络设置工作,因为地点、输入弧线和转换仍然存在。为了解决这些问题,让我们考虑测试方法和转换之间的关系。测试方法是调用被测系统并验证结果的代码,而转换只是方法与参数特征规范之间的绑定。如果我们能将规范部分以紧凑的形式表示,该形式可以展开为多个转换,那么我们就完成了,因为地点和输入弧线可以直接从转换派生出来。
我们需要做的第一件事是修复参数类别的表示,我选择通过向用于测试方法中参数的类添加布尔值和枚举类型属性来实现。来自系列文章第一篇的一个例子是 DateTime 实例的抽象表示:
public class MDateTime
{
#region Category definitions
public enum YearCategory { Min, CommonYear, LeapYear, CommonCentury,
LeapCentury, Max }
public enum MonthCategory { Jan, Feb, Jun, Jul, Aug, Dec }
public enum DayCategory { First, SecondLast, Last }
public enum TimeCategory { Min, Mid, Max }
#endregion Category definitions
#region Factors
public YearCategory Year { get; set; }
public MonthCategory Month { get; set; }
public DayCategory Day { get; set; }
public TimeCategory Hour { get; set; }
public TimeCategory Minute { get; set; }
public TimeCategory Second { get; set; }
public TimeCategory Millisecond { get; set; }
#endregion Factors
…
}
如果存在状态,如上面的 Bug 报告示例,只需添加另一个因子来表示状态,并由测试方法负责适当地更新状态。
类和组合属性一起,例如 [Year.CommonYear, Month.Feb, Day.Last, …] 定义了对象的整体类别,因此也定义了它所属的地点。元组/文本表示对于输入规范来说很好,而属性表示对于反射来说则很方便。现在有足够的信息来表示 P/T 网的组成部分,但长列表的显式定义的元组通常不是紧凑的表示。我为 Quality Gate One Studio 开发的解决方案是一个通用的表达式语言,支持集合运算、关系函数、覆盖数组等。
其最终结果是一个您需要关注以下内容的系统:
- 数据建模(主要定义类别和因子)
- 实现通用的参数化测试方法
- 定义要测试的内容。
然后系统通过观察输出来执行测试,并根据需要进行依赖性解析。
下载 QS 时,有一个示例说明如何驱动和测试有状态系统。
测试异步系统
到目前为止,模型一直是同步的,转换在被选定执行时立即触发。为了处理异步系统,我们需要支持异步执行。如果您查看 P/T 网模拟器的代码,您会找到一个名为“Fixture”的类,它表示一个具有(部分)参数绑定的转换。支持异步执行的第一步是使用队列来分隔 Fixture 的选择和 Fixture 的执行,接下来提供一种机制,可以将 Fixture 暂时挂起,直到某个事件释放它。
什么可以使 Fixture 挂起?一种选择是向存储在 P/T 网地点的对象添加“ReadyAt”时间戳。此功能支持以下场景:提交订单(例如在网上商店)会产生单独的电子邮件确认,该确认会在两分钟内到达。解决方案是让提交订单的转换发出一个“ExpectedEmail”对象,该对象的时间戳设置为在提交订单后两分钟可用。当检查电子邮件的下一个转换获取 ExpectedEmail 对象时,相关的 Fixture 将挂起,直到它准备就绪。
这种方法仅在预期副作用在提供的时间范围内可观察时有效,并且仅使用此机制,您必须使用最坏情况时间范围来防止测试中的误报。
为了加快速度,我们需要一种机制来提供正常情况下的时间范围,以及一种机制来在第一次时间范围过于乐观时稍后重试转换。因此,我们需要的第二种机制是重试转换并将其放回队列的能力。在 QS 中,您可以通过调用 TestContext.TryLater() 来实现这一点。
以上提供了轮询的基础,虽然它可以支持大多数异步场景,但它会使测试方法代码复杂化,并且感觉相当笨拙。
许多异步系统支持完成回调。也就是说,在异步操作完成时调用由用户提供的委托。为了支持这一点,我们需要能够触发转换(以启动异步操作)、挂起它并在收到完成回调后恢复它。对于 QS,我选择支持 continuations,它们的工作原理如下:在启动异步操作之前,将执行完成时要执行的代码(这是 continuation 代码,例如验证代码)的委托传递给一个方法 CreateContinuation(),该方法返回另一个委托用作回调。当调用回调时,它会将任何参数传递给 continuation 并释放相关的 Fixture。Continuations 会带来更优雅的用户代码和更优的调度。
上述机制支持通过触发转换/调用测试方法启动的异步操作,但对于计时器事件或实时数据馈送事件等无请求事件呢?我还没有找到针对此问题的良好解决方案:监视这些事件并向地点添加一个对象(例如“NewsUpdate”)然后等待一个转换来消耗该对象并执行其验证很简单。问题是我们无法预先知道接收数据的类别,我们不知道何时会收到事件,如果我们监视多个类别,我们也不知道是否会收到所有类别的所有数据。也许这就是事物的本质,但关于如何处理它的任何想法都非常受欢迎!
非确定性
有时转换不是完全确定的。它每次调用都会向不同的地点输出,并且确定转换的所有输出是不够的。在这种情况下,重要的是要记住,只有依赖性分析需要确定性输出弧线。只要输出在运行时进入正确地点,并且有足够的确定性输出弧线供依赖性解析器启用所有转换,就足够了。出于这个原因,QS 区分了已检查和未检查的输出,其中只有已检查的输出必须是确定性的。
如果依赖性解析严格需要一个输出弧线,但转换是非确定性的,有时提供所需的输出,有时不提供,那么测试方法可以向 QS 信号是否可用所需的输出。在这种情况下,QS 可以用新的输入再次调用该方法,直到它观察到所需的输出。
结论
在本文中,我研究了状态机和 P/T 网作为驱动测试的模型。状态机对复杂性的响应能力较差,而 P/T 网则具有更好的特性并提供了一些有趣的机遇。将依赖性解析引入 P/T 网模型使我们能够选择任何活动的转换(即测试用例),并让网络决定它可以在其执行之前所需的步骤顺序。在集成场景中,这意味着您不再需要 Arrange 代码。此外,依赖性解析支持稀疏的负载场景规范,比使用传统的硬编码序列更容易使用。使用此技术实现的序列和负载测试类型被称为马尔可夫链统计测试(MCST)。与 MCST 中的常见方法相比,使用带有依赖性解析的 P/T 网,不需要指定所有转换概率。
附带的代码示例包含一个能够进行 MCST 的 P/T 网模拟器,但示例中提供的原始模型在扩展和维护方面存在问题,并且缺乏对异步系统和非确定性的支持。我进一步讨论了 Quality Gate One Studio 工具如何通过引入形式化测试设计技术并利用这些技术来实现网络拓扑的自动发现来处理这些问题。这极大地提高了可扩展性,并使组合测试、基于模型的测试和 MCST 适用于真实复杂性的系统。
我希望您喜欢这篇文章,并且它为您提供了新的见解和灵感。对我而言,测试最初是一项相当枯燥的工作,后来却演变成了一场穿越计算机科学许多不同领域的挑战之旅。
我系列文章中的下一篇(也是迄今为止的最后一篇)将讨论性能测试、高速计时的复杂性、应使用的统计数据和不应使用的统计数据、并发性等。