Visual Studio 2015

通过智能单元测试构建更好的软件

Pratap Lakshman

软件开发领域内的发布周期越来越短,并且这种趋势越来越明显。软件开发团队在瀑布模型中严格地按顺序排好规范、实现和测试的功能已经是很久远的事了。在这种忙乱的世界中开发高质量软件很难,并且需要重新评估现有的开发方法。

若要减少软件产品中的 bug 数量,所有团队成员必须就软件系统要做的任务达成一致,而这是最大的挑战。规范、实现和测试通常互相隔绝,没有共同的沟通渠道。每个人使用的不同语言或项目使得他们难于随着软件实现活动的进行共同改进,因此,虽然规范文档应该与所有团队成员的工作紧密相连,但在现实中却很少是这样。最初的规范和实际的实现可能发生偏离,而最后将所有一切连在一起的只能是代码,并最终体现的是最后确定下来的规范和各种途中做出的设计决定。测试将尝试通过仅测试少数易于理解的端到端方案来协调此分歧。

这种情况可以得到改善。我们需要一个指定软件系统预期行为的公用介质,可以在设计、实现和测试之间共享,这也容易向前推进。该规范必须直接与代码关联,并且该介质必须编码为一套详尽的测试。智能单元测试支持的基于工具的技术可以帮助满足这一需求。

智能单元测试

智能单元测试是 Visual Studio 2015 预览版的一项功能(参见图 1),它是软件开发的智能助手,帮助开发团队及早发现 bug 并降低测试维护成本。它基于以前称为“Pex”的 Microsoft Research 工作。其引擎使用白盒代码分析和约束求解合成精确的测试输入值,以执行受测试代码中的所有代码路径,进而将这些持久保存为一整套具有高覆盖度的紧凑型传统单元测试,并自动随代码的优化而改进测试套件。

智能单元测试完全集成在 Visual Studio 2015 预览版中
图 1 智能单元测试完全集成在 Visual Studio 2015 预览版中

此外,强烈建议,代码中指定为声明的正确性属性可用于进一步指导测试用例生成。

默认情况下,如果您不执行任何操作而只是对一段代码运行智能单元测试,生成的测试用例将为每个合成的输入值捕获观察到的受测试代码行为。在此阶段,除了会导致运行时错误的测试用例,其余都会认定为通过测试 — 毕竟,这就是观察到的行为。

此外,如果您编写声明指定受测试代码的正确性属性,则智能单元测试将提供可导致声明失败的输入值,以及揭示代码中 bug 的每个此种输入值,并因此使测试用例失败。智能单元测试自身不能提供这种正确性属性;您最好根据自己的域知识对其进行编写。

测试用例生成

一般情况下,程序分析技术介于以下两个极端之间:

  • 静态分析技术,验证属性是否在所有执行路径上都为 true。因为目标是程序验证,所以这些技术通常过于保守并将可能的违规标记为错误,从而导致误报。
  • 动态分析技术,验证属性是否在某些执行路径上为 true。测试采用一种动态分析方法,目的是为了检测 bug,但其通常不能证明没有错误。因此,这些技术通常无法检测出所有错误。

只应用静态分析或采用一种无法识别代码结构的测试技术就精确地检测出 bug 是不太可能的。以下列代码为例:

int Complicated(int x, int y)
{
  if (x == Obfuscate(y))
    throw new RareException();
  return 0;
}
int Obfuscate(int y)
{
  return (100 + y) * 567 % 2347;
}

静态分析技术倾向于保守,因此 Obfuscate 中出现的非线性整数运算会导致大多数静态分析技术发出 Complicated 中可能存在错误的警报。此外,随机测试技术找到一对触发异常的 x 和 y 值的机会微乎其微。

智能单元测试可实现一种介于这两种极端之间的分析技术。类似于静态分析技术,其可针对大多数可行路径证明属性值。类似于动态分析技术,其仅报告真正的错误并且不会误报。

测试用例生成涉及以下内容:

  • 动态发现受测试代码中的所有分支(显式和隐式)。
  • 合成执行这些分支的精确测试输入值。
  • 记录受测试代码的输出以用于上述输入。
  • 将这些内容持久保存为一套具有高覆盖度的紧凑型测试。

图 2 显示了其使用运行时检测和监视的工作方式,以下是所涉及的步骤:

  1. 受测试代码首先进行检测并植入回调以允许测试引擎监视执行。然后,使用最简单的相关具体输入值(基于参数的类型)运行该代码。这表示初始测试用例。
  2. 测试引擎监视执行、计算每个测试用例覆盖范围并跟踪输入值在代码中的流动方式。如果所有路径都覆盖到了,则过程会停止;所有的异常行为都将视为分支,就像代码中的显式分支。如果并未涵盖到所有路径,则测试引擎将挑出达到未覆盖分支所离开的程序点的测试用例,并确定分支条件依赖于输入值的方式。
  3. 该引擎将构造一个表示条件的约束系统,在此条件下控制达到该程序点后,将继续沿着之前未覆盖的分支进行。然后,其将查询约束求解器以基于该约束合成一个新的具体输入值。
  4. 如果该约束求解器可以为该约束确定一个具体的输入值,则受测试代码将以新的具体输入值运行。
  5. 如果覆盖范围有所增加,则会发出一个测试用例。

测试用例生成实际的工作原理
图 2 测试用例生成实际的工作原理

重复步骤 2 到 5,直到涵盖所有分支,或超出预配置的探索边界。

此过程称为“探索”。在探索期间,受测试代码可以“运行”若干次。这些运行会增加覆盖范围,并且,仅那些增加覆盖范围的运行会发出测试用例。因此,生成的所有测试都将执行可行路径。

无限探索

如果所测试代码不包含循环或无限递归,则探索通常将很快停止,因为仅有限(小)数量的执行路径要分析。然而,最有意思的程序确实会包含循环或无限递归。在这种情况下,执行路径的数量(几乎)是无限的,并且通常不可判定语句是否可获得。换言之,探索将会一直循环分析程序的所有执行路径。由于测试生成涉及受测试代码的实际运行,因此您如何防止这种失控的探索?这时,有限探索就可以发挥其关键作用了。它可以确保在一段合理的时间后停止探索。以下是几个用到的可配置分层探索边界:

  • 约束求解器边界限制求解器在搜索下一个具体输入值时可以使用的时间量和内存。
  • 探索路径边界限制要分析的执行路径的复杂性,包括获取的分支数量、针对需要检查的输入的条件数量,以及在堆栈帧方面的执行路径的深度。
  • 探索边界限制不会生成测试用例的“运行”数量、允许的运行总数以及探索停止后总体时间限制。

若要使任何基于工具的测试方法有效,最重要的一个方面是快速反馈,所有这些边界都经过了预配置,支持快速交互使用。

此外,测试引擎使用试探法来快速实现较高的代码覆盖度,方式是通过推迟解决硬约束系统。您可以让该引擎针对您正在使用的代码迅速生成一些测试。但是,若要解决其余的硬测试输入生成问题,您可以提高阈值以扩大测试引擎在复杂约束系统上的运行范围。

参数化单元测试

所有程序分析技术都会尝试验证或反驳给定程序的某些特定属性。以下是用于指定程序属性的不同技术:

  • API 协定从实现的角度指定各个 API 操作的行为。其目标是保证可靠性,即操作不会崩溃,数据不变量会得到保留。API 协定的一个常见问题是其对于各个 API 操作的了解有限,这便很难描述系统范围的协议。
  • 单元测试从 API 的客户端视角体现了典型的使用方案。其目的是保证功能正确性,即若干操作按预期方式进行交互作用。单元测试的一个常见问题是其与 API 实现的详细信息相分离。

智能单元测试支持参数化单元测试,此测试将这两种技术结合在了一起。受测试输入生成引擎支持的这一方法将客户端与实现角度融合在了一起。在多数实现(测试输入生成)中,都会检查功能正确性属性(参数化单元测试)。

参数化单元测试 (PUT) 是通过使用参数直接泛化单元测试。PUT 为整套可能的输入值制定出关于代码行为的语句,而不仅仅是针对一个典型输入值。它对测试输入表达假设、执行一系列操作,并断言应在最终状态中存在的属性;即,它充当着规范的角色。这样一种规范不会要求或引入任何新的语言或项目。它是以软件产品的编程语言在软件产品实现的实际 API 级别进行编写的。设计人员可以使用它来表达软件 API 的预期行为,开发人员可以使用它来驱动自动化开发人员测试,测试人员可以利用它进行深入的自动测试生成。例如,以下 PUT 断言将元素添加到非空列表之后,该元素确实会包含在列表中:

void TestAdd(ArrayList list, object element)
{
  PexAssume.IsNotNull(list);
  list.Add(element);
  PexAssert.IsTrue(list.Contains(element));
}

PUT 分开了以下两个问题:

  1. 针对所有可能测试参数的受测试代码的正确性属性的规范。
  2. 包含具体参数的实际“已关闭”测试用例。

该引擎针对第一个问题发出存根,并鼓励您根据自己的域知识对其进行填充。随后对智能单元测试进行调用,这将自动生成并更新个别已关闭的测试用例。

应用程序

软件开发团队的脑海中可能已盘踞着各种各样的方法,期望他们一夜之间采用新的方法并不现实。实际上,智能单元测试并不会取代团队可能已遵循的任何测试做法;相反,其是对所有现有做法的一种增补。我们可以逐渐推进新方法的采用:团队首先利用默认的自动测试生成和维护功能,然后再转而使用代码编写规范。

测试观察到的行为设想必须对代码主体进行与测试范围无关的更改。您可能想要在开始之前,根据单元测试套件确定其行为,但是说起来容易做起来难:

  • 代码(产品代码)可能会不适于进行单元测试。它可能与需要隔离的外部环境有着紧密的依赖关系,如果不能识别这些依赖关系,您可能甚至不知道从何处开始。
  • 测试的质量可能也是个问题,并且衡量质量的标准繁多。例如,覆盖范围的指标 — 测试会涉及到产品代码中多少分支、代码路径或其他程序项目?再比如,声明的指标 — 表示代码是否执行了正确的任务。但是,这两个指标本身是不够的。相反,最好是使用高代码覆盖率验证高密度声明。但是,在编写测试时,在您的头脑里进行这种质量的分析并非易事,因此,您最终可能编写出一个重复执行代码路径的测试;或许其只是在测试“令人满意的路径”,您将永远不会知道产品代码是否可以应对所有这些边缘情况。
  • 而且,令人沮丧的是,您可能甚至不知道置入了哪些声明。设想一下您被叫去修改毫不熟悉的基本代码时的情况吧!

智能单元测试的自动测试生成功能在此情况下特别有用。您可以将当前观察到的代码的行为作为基准,以用作回归套件的一套测试。

基于规范的测试软件团队可以使用 PUT 作为规范,以驱动详尽测试用例生成,从而揭示违反测试声明。编写实现较高代码覆盖率的测试用例需要大量的手动工作,若要解放双手,团队可以专注于智能单元测试不能自动执行的任务,如编写和 PUT 一样更为有意思的方案、开发超出 PUT 范围的集成测试。

自动查找 Bug 表达正确性属性的声明可以通过多种方式表示:声明语句、代码协定等等。可喜的是,这些都可以编译到分支 — if 语句,带有 then 分支和 else 分支,表示断言谓词的结果。因为智能单元测试计算执行所有分支的输入,所以其将成为有效的 bug 查找工具,以及 — 任何其提供的可能触发 else 分支的输入都表示受测试代码中存在 bug。因此,报告的所有 bug 都是实际的 bug。

减少测试用例维护 PUT 的存在可显著降低需要维护的测试用例数量。在还需要手动编写单个已关闭测试用例的领域,如果受测试代码进行了优化,那会发生什么情况?您必须一个一个地修改所有测试的代码,这也意味着高昂的成本。然而,通过编写 PUT,就只需要对 PUT 进行维护。然后,智能单元测试可自动重新生成单个测试用例。

挑战

工具限制使用白盒代码分析与约束求解的技术非常适用于隔离良好的单元级代码。然而,测试引擎也存在一些限制:

  • 语言:原则上,该测试引擎可以分析以任何 .NET 语言编写的任意 .NET 程序。但是,测试代码仅以 C# 生成。
  • 非确定性:该测试引擎假定受测试代码是确定的。如果不是,其将删除非确定性的执行路径,或者其可能会进入循环直至碰到探索边界。
  • 并发:该测试引擎不处理多线程的程序。
  • 未检测的本机代码或 .NET 代码:该测试引擎无法识别本机代码,即,x86 指令通过 Microsoft.NET Framework 的平台调用 (P/Invoke) 功能进行调用。该测试引擎并不知道如何将此种调用转换为可以由约束求解器解决的约束。甚至对于 .NET 代码,该引擎仅可以分析其检测的代码。
  • 浮点运算:该测试引擎使用自动约束求解器确定哪些值与测试用例和受测试代码相关。但是,约束求解器的能力有限。特别是,它不能就浮点运算进行准确推论。

在这些情况下,该测试引擎将通过发出警报提醒开发人员,可使用自定义属性控制处于这些限制下的引擎行为。

编写出良好的参数化单元测试编写出良好的 PUT 可能具有挑战性。以下是两个需要回答的核心问题:

  • 覆盖范围:执行受测试代码的良好方案(方法调用的顺序)是什么?
  • 验证:可以轻松表示而无需重新实现算法的良好声明是什么?

PUT 只有在能够回答这两个问题的情况才能发挥其作用。

  • 没有足够的覆盖范围;即,如果该方案覆盖面过去狭窄而无法到达所有受测试代码,则 PUT 的范围也会受到限制。
  • 没有足够的计算结果验证;即,如果 PUT 没有包含足够的声明,则它不能确定代码是否执行了正确的任务。PUT 所做的所有事情就是确定受测试代码没有崩溃或没有运行时错误。

在传统的单元测试中,需要回答的问题还要加上下面这个:相关的测试输入是什么?使用 PUT 时,这个问题将由工具来关注。但是,在传统单元测试中,查找良好声明的问题更容易:这些声明往往更简单,因为它们是针对特定测试输入编写的。

总结

Visual Studio 2015 预览版中的智能单元测试功能允许您以源代码方式指定软件的预期行为,并结合使用自动化的白盒代码分析与约束求解器以生成并维护一整套可高度覆盖您的 .NET 代码的紧凑型测试。跨界优势 — 设计人员可以使用其来指定软件 API 的预期行为;开发人员可以使用其来驱动自动化开发人员测试;测试人员可以利用其进行深入的自动测试生成。

软件开发领域中不断缩短的发布周期推动了许多与规划、规范、实现和测试相关的活动。这个忙碌的世界使得我们不得不围绕这些活动重新评估现有的做法。短期、快速、重复的发布周期需要将这些功能组之间的协作提升至一个新的级别。诸如智能单元测试这样的功能可帮助软件开发团队更加轻松地达到这些级别。


Pratap Lakshman 就职于 Microsoft 开发部门,目前是 Visual Studio 团队的高级项目经理,致力于测试工具。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Nikolai Tillmann