使用 PEX 进行白盒测试
单元测试套件的维护问题,使用 PEX 协助解决,以及 PEX 的各种概念,如 PEX Factories、PEX Method (PUT)、Parameterize Mock 和 Partial Stubs。
引言
在本文中,我们将探讨单元测试套件的维护问题,使用 PEX 协助解决,以及 PEX 的各种概念,如 PEX Factories、PEX Method (PUT)、Parameterize Mock 和 Partial Stubs。
使用 PEX 作为白盒测试工具
问题
单元测试并非新事物,它已经存在了几十年,并在单元测试的概念上有重要的历史。围绕单元测试开发了各种软件开发流程,如 TDD(测试驱动开发)、BDD(行为驱动开发)。单元测试有利有弊,超出了本文的范围。但是,为了使本文的其余部分有意义,需要对单元测试有基本的了解。
我将在此讨论维护单元测试用例的问题。当一个项目处于开发阶段时,开发团队的热情很高,他们阅读规范,创建测试用例,并使用 TDD 的概念编写代码。一切顺利!团队生成了数千个单元测试用例。这些测试用例堆积在单元测试套件的孤岛中。团队热衷于设置持续集成,其中每两周执行和签入这些堆积的测试用例,并通过自动化电子邮件将测试报告发送给团队中的相关人员。
到目前为止,一切顺利!现在,随着截止日期临近,团队的紧张情绪高涨,在测试周期中代码有很多修复,但开发人员开始偷工减料,未能更新代码更改的测试用例。许多单元测试开始失败,代码覆盖率下降,代码质量也随之下降。开发人员急于编写的每一行新代码都可能在系统中引入新的 bug。这些 bug 与边界条件、未处理的异常以及模式的架构违规有关。
在交付结束时,单元测试套件包含许多失败的测试,原因仍然未知,项目初期闪闪发光的代码现在已经破败不堪,无法修复,在各个地方断裂,并面临不可预测的条件。
结果是,维护支持增加,集成混乱,此后成本飙升,这又使客户的面子上挂不住。
一旦我们理解了问题,就有一系列自动化工具的需求,这些工具可以找到代码中隐藏的 bug,深入到每一行代码,遍历分支,揭示代码可能出现问题的地方,并在最后时刻的压力和偷工减料造成的代码混乱中充当救星。
基本上,从技术上讲,我们正在寻找一个白盒测试工具,它可以读取代码行并告诉我们哪里出了问题。它还解决了测试套件陈旧的问题,即新编写的代码行会在不编写额外测试用例来覆盖它们的情况下被自动探索和检查 bug。
PEX
为了拯救这一天,我将介绍 PEX。PEX 是一个白盒测试工具,在我写这篇文章时仍处于 beta 阶段,由位于华盛顿州雷德蒙德的 Microsoft Research and Development 团队开发。
我不会写太多关于 PEX 的内容,因为它已经被写了很多。你可以在 http://research.microsoft.com/en-us/projects/pex/ 找到更多信息。我将专注于使用 PEX 单元测试用例来解决我们上面描述的问题的方法。
目标
- 以自主的方式探索我们的代码,查找边界条件、崩溃测试和未处理的异常,而无需在编写测试用例方面付出太多努力。
- 随着新代码行的编写,使单元测试套件随着时间的推移不会过时。
解决方案
为了演示 PEX,我创建了一个如以下所示的过度简化的项目。该项目有三个层(Entity、DAL 和 Insurer)。层架构如下所示。
Insurer
和 DAL
都引用 Entity
,而 Insurer
引用 DAL
。
在这里,我们将测试 Insurer
类中的两个方法。
第一个是 IsPersonEligible
,它根据代码中定义的业务规则来验证 Person
是否有资格获得保险。
public virtual bool IsPersonEligible(Person p)
{
Contract.Requires(p != null);
List SelectedAreasZipCodes = new List { "400001", "400002" };
if (p.Age > 17 && p.Age < 45 & SelectedAreasZipCodes.Contains(p.pAddress.ZipCode))
{
dal.saveInsurePerson(p);
return true;
}
else
{
return false;
}
}
DAL
通过构造函数使用 IOC 原则注入到 Insurer
类中。
public class Insurer
{
public Insurer(DAL.IDAL dal)
{
this.dal = dal;
}
}
现在,让我们专注于第一个方法的测试。
public virtual bool IsPersonEligible(Person p);
为了编写手动单元测试,您需要将 person 对象模拟(stub)到特定状态。有关 stub 和 mock 的更多信息,请参阅 https://martinfowler.com.cn/articles/mocksArentStubs.html。
必须编写三个测试用例才能覆盖测试方法的 100%。
- 单元测试一:将
Person
传递为null
,并期望合同 null 异常。 - 单元测试二:模拟
Person
并将Person
对象设置为某种状态,该状态不满足业务规则,并期望方法返回false
。 - 单元测试三:模拟
Person
并将Person
对象设置为某种状态,该状态满足业务规则,并期望方法返回true
。
现在,当开发人员编写所有三个测试用例时,他们假设此方法的代码覆盖率为 100%,代码可以正常使用。但是您认为这些测试用例足够吗?它们是否涵盖了边界条件、未处理的异常和隐藏的 bug?
十万火急,PEX 来救援了。快速运行,只需右键单击方法体内的任何位置,然后选择“运行 Pex”。
然后看看神奇的发生。Pex 将探索您的方法,并尝试找出由于边界条件、未处理的异常和隐藏 bug 而引起的所有 bug。
它将自行探索被测代码,以这样的方式创建 Person
的模拟,从而遍历代码中的每个分支,以获得方法 100% 的覆盖率。
上面是运行 PEX 代码后的结果。
但生活并非如此轻松,我们不编写简单的对象,我们的系统充满了复杂的对象以及它们之间的交互。当您对涉及创建复杂对象(box)的代码进行单元测试时,PEX 最初会努力自行找出对象创建的问题,但对于现代对象,如处理 httpcontext、viewbag 和各种其他依赖项的控制器,PEX 无法创建此类对象。
在这种情况下,您需要创建 Pex Factories 方法,这将帮助 PEX 在特定状态下创建对象,该对象可用于测试您的被测对象。在这里,我强调了特定状态,因为当您创建一个工厂方法时,您会在需要状态的对象的状态下实例化它。例如,当您实例化一个控制器时,该控制器会持有 httpSession 状态,并且您正在测试的方法期望在调用该方法之前将某个值存储在 session bag 中。因此,在工厂中,您在设置 httpContext 到控制器时,会在 session 中添加特定值。
此外,并非总能在工厂中设置状态。考虑这种情况,您正在跨多个方法共享工厂。您将设置对所有方法通用的状态,但特定地,您需要在 PEX 将用于探索的特殊单元测试方法中设置。这些特殊方法称为 **Pex Methods**。
抽象的谈论足够了,让我们来看一个例子。
public virtual bool IsPersonEligible(Person p)
现在,为了测试这个方法,我们需要先实例化包含这个方法的 Insurer
类。那么问题是什么?如果您检查 Insurer
的构造函数,只有一个公共构造函数,它将 IDAL(数据访问层依赖项)作为参数。
PEX 遇到问题,因为 PEX 不知道如何模拟并将 IDAL 对象传递给 Insurer
类。PEX 工厂方法应运而生!
我们需要为 Insurer
类创建一个工厂方法,PEX 在需要实例化 Insurer
类时将使用该方法。
/// <summary>A factory for SampleApp.Insurer instances</summary>
[PexFactoryMethod(typeof(Insurer))]
public static Insurer Create()
{
DAL.Moles.SIDAL Mdal = new DAL.Moles.SIDAL();
Insurer insurer = new Insurer(Mdal);
return insurer;
}
在这里,在工厂中,我使用了 Moles 来模拟 IDAL 并将其传递给 Insurer
对象。我不会在此讨论 Moles,因为它需要一套单独的文章来描述它。但是您可以在 http://research.microsoft.com/en-us/projects/moles/ 找到更多关于 Moles 的信息。
此外,我们还将为 IsPersonEligible
创建一个 PEX 方法。只需单击方法下的任意位置,然后选择“创建参数化单元测试”,按照向导,选择您喜欢的测试框架,然后“砰!”就完成了。Pex 创建了一个样板 PUT(参数化单元测试),您可以直接使用它,也可以根据需要进行自定义。
/// <summary>Test stub for IsPersonEligible(Person)</summary>
[PexMethod]
public bool IsPersonEligible([PexAssumeUnderTest]Insurer target, Person p)
{
bool result = target.IsPersonEligible(p);
return result;
// TODO: add assertions to method InsurerPexTest.IsPersonEligible(Insurer, Person)
}
要探索您的方法,只需右键单击 PexMethod
内部,然后单击“探索”。
到目前为止,我涵盖了两个概念:**Pex Factories** 和 **Pex Method**。Pex Methods 就是 **PUT**。现在,让我们看看这两个概念如何帮助解决我们的问题。
一旦您编写了 Pex Factories 和 Pex Methods,您就可以在持续集成环境中设置 Pex 运行。即,每当开发人员签入代码时,或者如果团队人数较多(10+ 位开发人员),您可以每两周设置一次。Pex 将使用 **Pex Factories** 和 **Pex Method** 进行探索,并在代码出现错误时通过电子邮件将报告发送给您,哇!
这里的重点是,没有编写新的测试用例,也没有进行代码审查,但仍然检测到了边界条件、未处理的异常和崩溃值。因此,它提高了代码中 bug 的检测率,修复这些 bug 可以提高代码质量。
我想让您亲眼看看我的观点,请考虑下面被测方法。
public virtual bool IsPersonEligible(Person p)
{
Contract.Requires(p != null);
List<string> SelectedAreasZipCodes = new List<string> { "400001", "400002" };
if (p.Age > 17 && p.Age < 45 & SelectedAreasZipCodes.Contains(p.pAddress.ZipCode))
{
int zipcode = Convert.ToInt32(p.OtherAddresses[0].ZipCode);
dal.associateZipCodewithPersonIndex(p);
dal.saveInsurePerson(p);
return true;
}
else
{
return false;
}
}
粗体代码是由一个“僵尸开发者”添加的,这将破坏优雅。如果 Person
的 OtherAddresses
集合中没有地址,索引 0 将引发索引越界异常。如果 OtherAddresses
中的 zip code 为 null 或非数字怎么办?这将引发 null 异常或解析异常。如果 zip code 大于 int
的最大值怎么办?这将引发溢出异常。开发人员没有考虑边界条件和异常处理,PEX 将会发现所有这些。
这些异常也可以通过手动编写的测试用例捕获,但在手动测试用例中,您需要手动创建一个 Person
模拟。如果模拟包含 OtherAddresses
,并且设置了值,则不会捕获边界条件异常。
在 PEX 探索中,PEX 生成 Person
模拟,从而揭示隐藏的异常。
为了改进代码,开发人员需要检查边界条件并实现适当的异常处理。
在战场上,骑士需要士兵的帮助。这些是 **参数化模拟(parameterized mocks)和部分模拟(partial stubs)**。要理解这一点,让我们再举一个方法作为例子。
Insurer
类中的第二个方法是 GetInsureAmount
,它在检查 person 是否符合业务规则后,从 DAL 存储库中检索 person 的投保金额。
这里,DAL 是数据访问存储库,它尚未实现,因为它超出了 Insurer
类单元测试的范围。
public float GetInsureAmount(Person p)
{
float rtnAmount = 0.0f;
if (this.IsPersonEligible(p))
{
Policy ply = dal.getInsursePersonPolicy(p);
if (ply.InsureAmount > 0)
rtnAmount = ply.InsureAmount;
else
rtnAmount = 0.1f;
}
else
throw new NotEligibleException("Person " + p.Name + "not eligible for insurance");
return rtnAmount;
}
如果我们研究该方法,该方法有两个嵌套分支。为了通过第一个分支(if-else)进行正向流程,我们需要一个 stub person 对象,使其处于满足条件的某种状态。使用该 stub,IsPersonEligible
将返回 true
。
为了通过第二个分支,我们需要从 dal.getInsursePersonPolicy
返回一个 InsureAmount
大于零的 policy。等等,您看到问题了吗?dal
是我们在 Pex 工厂的 Insurer
构造函数中注入的 mock 层。mock 层没有实现体,因此当 Pex 探索时不会返回任何 policy。
解决方案?我们需要以这样的方式伪造对 dal.getInsursePersonPolicy
的调用,使其返回一个 InsureAmount
大于零的 policy。
我们该怎么做?在 Insurer
的 Pex 工厂中,
[PexFactoryMethod(typeof(Insurer))]
public static Insurer Create()
{
DAL.Moles.SIDAL Mdal = new DAL.Moles.SIDAL();
Mdal.GetInsursePersonPolicyPerson = ((p) =>
{
Policy ply = new Policy();
ply.PolicyNumber = 123;
ply.InsureAmount = 100;
return ply;
});
Insurer insurer = new Insurer(Mdal);
return insurer;
}
粗体代码利用 Mole 框架的委托功能,对 dal.getInsursePersonPolicy
进行了伪造调用。
只需运行此方法的 Pex 探索并查看结果。Pex 无法 100% 覆盖被测方法中的代码。为什么?因为我们模拟的 Policy
始终返回 InsureAmount
为 100,因此 Pex 永远无法探索第二个分支的 else 条件。
解决方案?我们需要以某种方式告诉 Pex,在一处测试用例中将 InsureAmount
设置为 0,而在另一处测试用例中将 InsureAmount
设置为 100。这样我们就可以覆盖第二个分支的两个条件。但是如何做到呢?
使用 PexChoose
。PexChoose
是 Microsoft.Pex.Framework 中的静态方法,它设置 Pex 探索符号执行所需的值。我不会深入探讨 Pex 探索方法,使用约束求解器 Z3 引擎,以及 PexChoose
如何参与 PEX 生成的约束求解方程,您可以在 http://research.microsoft.com/en-us/projects/pex/ 获取所有这些详细信息。
但通俗地说,它告诉 PEX 在一个测试用例中使用 0,在另一个它执行的测试用例中使用大于 0 的任何值,以 100% 覆盖方法分支。
PexChoose 还有另一个优点。如果业务规则明天发生变化,并出现新条件,如下所示:
if (ply.InsureAmount > 10)
rtnAmount = ply.InsureAmount;
else
rtnAmount = 0.1f;
我们不需要更改 Pex Factories 和 Pex Method,PEX 探索将发现新条件,并基于 PexChoose
生成新的 Policy 模拟,并确保它能够覆盖代码的两个分支。
带有 **Parameterize Mock** 的新 Pex Factory 方法如下所示:
[PexFactoryMethod(typeof(Insurer))]
public static Insurer Create()
{
DAL.Moles.SIDAL Mdal = new DAL.Moles.SIDAL();
Mdal.GetInsursePersonPolicyPerson = ((p) =>
{
Policy ply = new Policy();
ply.PolicyNumber = PexChoose.Value<int>("PolicyNumber");
ply.InsureAmount = PexChoose.Value<float>("InsureAmount");
PexObserve.Value<Policy>("PolicyReturn", ply);
return ply;
});
Insurer insurer = new Insurer(Mdal);
return insurer;
}
在这里,您学习了一个新概念 **Parameterize Mock**,并了解了它如何帮助 Pex 探索。现在我已经涵盖了 **Pex Factories、Pex Method** 和 **Parameterize Mock**,还有一个概念我需要提及:partial stub(部分模拟)。
让我们回顾第二个方法 GetInsureAmount
。
public float GetInsureAmount(Person p)
{
float rtnAmount = 0.0f;
if (this.IsPersonEligible(p))
{
Policy ply = dal.getInsursePersonPolicy(p);
if (ply.InsureAmount > 0)
rtnAmount = ply.InsureAmount;
else
rtnAmount = 0.1f;
}
else
throw new NotEligibleException("Person " + p.Name + "not eligible for insurance");
return rtnAmount;
}
这里您可以看到,在测试 GetInsureAmount
时再次测试了 IsPersonEligible
。在我们的例子中,这是可以的,也很简单。考虑另一种情况,其中 IsPersonEligible
是一个复杂的方法,它又调用了另一个第三方 DLL。在 GetInsureAmount
的单元测试上下文中,**我们不想测试** IsPersonEligible
。请记住,我们正在进行单元测试而不是集成测试。IsPersonEligible
的测试将在对该方法进行单元测试时进行。
这里存在一个问题。IsPersonEligible
是 Insurer
类本身的公共方法,我需要一种方法来伪造测试用例中的 IsPersonEligible
调用,但不是 GetInsureAmount
调用。
一种解决方案是将交互解耦,将 IsPersonEligible
放入另一个类中,并在 Insurer
类中进行依赖注入,这样我就可以伪造对 IsPersonEligible
的调用。但并非总是可行。有些情况下,您无法更改类蓝图。
这就是 **Partial Stubs** 的概念,我将模拟 Insurer
类,以便能够伪造 IsPersonEligible
调用,但让 GetInsureAmount
调用真实对象。
public static Insurer Create01()
{
DAL.Moles.SIDAL Mdal = new DAL.Moles.SIDAL();
Mdal.GetInsursePersonPolicyPerson = ((p) => PexChoose.Value<Policy>("Policy"));
SInsurer insurer = new SInsurer(Mdal) { CallBase = true };
insurer.IsPersonEligiblePerson = ((p) => PexChoose.Value<bool>("IsEligible"));
return insurer;
}
粗体代码模拟了 Insurer
类,其中 CallBase
设置为 true。也就是说,每当对 SInsurer
模拟上的任何方法进行调用时,它都会将其调用重定向到 Insurer
类的实际实现。
斜体行是我重写了 IsPersonEligible
方法,并使用 PexChoose
设置了一个参数化值,该值将根据 Pex 所需的符号约束条件返回 true 或 false。
现在让我们使用新的部分模拟运行 Pex 探索并查看结果。
Pex 找到了我们代码中的 bug。
Policy ply = dal.getInsursePersonPolicy(p);
if (ply.InsureAmount > 0)
遵循以上几行,我没有检查 dal.getInsursePersonPolicy
返回的 ply
是否为 null,而是直接访问 ply.InsureAmount
属性,然后抛出 NullReferenceException
!
摘要
哇,我已到达文章的结尾,这是总结:
- Pex 是白盒测试的一个优秀候选者,它可以帮助查找代码中的边界条件、崩溃测试和未处理的异常。
- 为了在复杂对象和交互中使用 Pex,我们需要使用 **Pex Factories 和 Pex Method**。
- 为了改进 Pex Factories 或 Pex Method 以参与 Pex 的符号执行,我们可以在其中使用 **Parameterize Mock**。
- 如果同一类中的方法之间存在硬依赖关系,并且我们需要对其进行单元测试,我们可以利用 Microsoft Research 团队的 Moles 模拟框架的强大功能,并可以使用 **Partial Stubs**。
接下来做什么?
- 访问 http://research.microsoft.com/en-us/projects/pex/
- 在线试用:www.pexforfun.com
- 阅读文档:http://research.microsoft.com/en-us/projects/pex/documentation.aspx
参考文献
- http://research.microsoft.com/en-us/projects/pex/
- 代码示例请访问 www.pexforfun.com
- Pex 用法:http://research.microsoft.com/en-us/projects/pex/documentation.aspx
- MSDN 杂志文章 - Nikhil Sachdeva:http://msdn.microsoft.com/en-us/magazine/ee819140.aspx
- CodeProject: Thomas Weller 文章:https://codeproject.org.cn/Articles/98378/Mocking-the-Unmockable-Using-Microsoft-Moles-with
- Dino Esposito 在 http://www.drdobbs.com/testing/pex-microsoft-researchs-unit-test-genera/240009056 的文章