测试运行

超简单的变化测试

James McCaffrey

下载代码示例

image: James McCaffrey
我认识的很多测试人员都听说过变化测试,却很少有人执行过该测试。变化测试以难度大、需要昂贵的第三方软件工具而闻名。但是,在本月专栏中我将为您展示如何用 C# 和 Visual Studio 创建一个超简单(不到 2 页代码,用不了 4 个小时)的变化测试系统。简单的变化测试系统能让您用很少的时间和精力,就获得成熟的变化测试系统所提供的大部分好处。

变化测试是一种评估一组测试用例有效性的方法。其原理很简单。假设您有 100 个测试用例,并且被测系统 (SUT) 通过了这 100 个测试。如果您改变 SUT,例如把“>”更改为“<”或把“+”更改为“-”,则有可能将一个错误引入到了 SUT。现在,如果您重新运行这 100 个测试用例,则可以预见至少有一个测试用例会失败,表明其中一个测试用例捕获了错误代码。但是,如果未看到测试失败,则很可能是您的这组测试用例错过了错误代码,未能彻底测试 SUT。

若要了解我将要介绍的内容,最好是看一下图 1

变化测试运行演示

图 1 变化测试运行演示

该示例中的 SUT 是一个名为 MathLib.dll 的库。我在这里介绍的方法可用于测试大部分的 Microsoft .NET Framework 系统,包括 DLL、WinForms 应用程序、ASP.NET Web 应用程序等。变化系统首先扫描 SUT 的原始源代码,寻找可进行更改的候选代码。我的超简单系统只搜索“<”和“>”运算符。测试系统设置为创建并评估两处变化。在实际的工作中,您可能会创建上百甚至上千个变化。第一处变化会随机选择并更改一个运算符,本示例中,是 SUT 源代码中字符位置 189 上的“>”运算符,并将此符号更改为“<”。接下来,构建变化 DLL 源代码以创建变化的 MathLb.dll 库。然后,变化系统调用变化的 SUT 上的一组测试用例,并将结果记录到文件中。第二次迭代会以同样的方法创建和测试第二处变化。日志文件的结果如下所示:

=============
Number failures = 0
Number test case failures = 0 indicates possible weak test suite!
=============
Number failures = 3
This is good.
=============

第一个变化未生成任何测试用例失败,这说明您应该检查 189 位置上的源代码并确定为何没有测试用例检查该代码。

SUT

我的超简单变化测试演示包含 3 个 Visual Studio 项目。第一个项目包含 SUT,在本例中是一个名为 MathLib 的 C# 类库。第二个项目是一个可执行的测试工具,在本例中是一个名为 TestMutation 的 C# 控制台应用程序。第三个项目创建和构建变化,在本例中是一个名为 Mutation 的 C# 控制台应用程序。为方便起见,我将三个项目放置到一个名为 MutationTesting 的目录中。在变化测试中要跟踪很多文件和文件夹,您不应忽视妥善组织它们所面临的难度。对于此演示,我使用了 Visual Studio 2008(可使用任何版本的 Visual Studio)来创建虚拟的 MathLib 类库。图 2 显示了该虚拟 SUT 的完整源代码。

图 2 虚拟 SUT 的完整源代码

using System;
namespace MathLib
{
  public class Class1
  {
    public static double TriMin(double x, double y, double z)
    {
      if (x < y)
        return x;
      else if (z > y)
        return y;
      else
        return z;
    }
  }
}

请注意,我保留了默认类名称 Class1。 该类包含一个静态方法 TriMin,它返回三个 Double 参数中最小的一个。 还要注意,该 SUT 有意设计的不正确。 例如:如果 x = 2.0、y = 3.0 并且 z = 1.0,该 TriMin 方法会返回值 2.0 而不是正确值 1.0。 但是,请务必注意,变化测试并不 直接衡量 SUT 的正确性,而是衡量一组测试用例的有效性。 构建 SUT 后,下一步就是将源文件 Class1.cs 的一个基准副本保存到变化测试系统的根目录中。 之所以这么做是因为,每次变化都会对 SUT 的原始源代码进行一次修改,因此必须保留一份 SUT 原始源代码。 在本示例中,我将原始源代码保存在 C:\MutationTesting\Mutation 中,名为 Class1-Original.cs。

测试工具

在某些测试中,您可能会有一组现成的测试用例数据,在另一些测试中,您可能会有现成的测试工具。 对于这个超简单变化测试系统,我创建了一个名为 TestMutation 的 C# 控制台应用程序测试工具。 在 Visual Studio 中创建该项目后,我添加一个对 SUT 的引用。MathLib.dll 位于 C:\MutationTesting\MathLib\bin\Debug。 图 3 显示了测试工具项目的完整源代码。

图 3 测试工具和测试数据

using System;
using System.IO;

namespace TestMutation
{
  class Program
  {
    static void Main(string[] args)
    {
      string[] testCaseData = new string[]
        { "1.0, 2.0, 3.0, 1.0",
          "4.0, 5.0, 6.0, 4.0",
          "7.0, 8.0, 9.0, 7.0"};

      int numFail = 0;

      for (int i = 0; i < testCaseData.Length; ++i) {
        string[] tokens = testCaseData[i].Split(',');
        double x = double.Parse(tokens[0]);
        double y = double.Parse(tokens[1]);
        double z = double.Parse(tokens[2]);
        double expected = double.Parse(tokens[3]);

        double actual = MathLib.Class1.TriMin(x, y, z);
        if (actual != expected) ++numFail;
      }

      FileStream ofs = new FileStream("..
\\..
\\logFile.txt",
        FileMode.Append);
      StreamWriter sw = new StreamWriter(ofs);
      sw.WriteLine("=============");
      sw.WriteLine("Number failures = " + numFail);
      if (numFail == 0)
        sw.WriteLine(
          "Number test case failures = " +
          "0 indicates possible weak test suite!");
      else if (numFail > 0)
        sw.WriteLine("This is good.");
      sw.Close(); ofs.Close();
    }
  }
}

您将看到,测试工具有三个硬编码的测试用例。 在实际工作中,您可能会有数百个测试用例存储在文本文件中,并将文件名作为 args[0] 传递到 Main 中。 在第一个测试用例中,“1.0, 2.0, 3.0, 1.0,”代表 x、y 和 z 参数(1.0、2.0 和 3.0),后面是 SUT 的 TriMin 方法的预期结果 (1.0)。 很明显,测试集是不充分的:这三个测试用例中的每一个基本上都是等效的,并且将最小值作为 x 参数。 但是,如果您研究原始的 SUT,您会发现事实上三个测试用例都会通过。 我们的变化测试系统能够检测出这个测试集的缺陷吗?

测试工具迭代每个测试用例,分析输入参数和预期返回值,使用输入参数调用 SUT,提取实际返回值,将实际返回值和预期返回值进行比较,从而确定测试用例结果为通过还是失败,然后累计测试用例失败的总数量。 请注意,在变化测试中,我们主要关注是否至少有一个新的测试用例失败,而不是有多少测试用例通过。 测试工具将日志文件写入到调用程序的根文件夹中。

变化测试系统

在这部分中,我将逐行向您介绍变化测试程序,但是省略了图 1 中所示的用于生产输出的 WriteLine 语句的大部分内容。 我在 MutationTesting 根目录中创建了一个名为 Mutation 的 C# 控制台应用程序。 该程序的开头如下所示:

using System;
using System.Collections.Generic;
using System.IO;
using System.Diagnostics;
using System.Threading;

namespace Mutation
{
  class Program
  {
    static Random ran = new Random(2);
    static void Main(string[] args)
    {
      try
      {
        Console.WriteLine("\nBegin super-simple mutation testing demo\n");
...

Random 对象的目的是生成一个随机的变化位置。 我使用了一个种子值 2,但其实任意值都可以。 接下来,设置文件位置:

string originalSourceFile = "..
\\..
\\Class1-Original.cs"; 
string mutatedSourceFile = "..
\\..
\\..
\\MathLib\\Class1.cs";
string mutantProject = "..
\\..
\\..
\\MathLib\\MathLib.csproj";
string testProject = "..
\\..
\\..
\\TestMutation\\TestMutation.csproj";
string testExecutable = 
  "..
\\..
\\..
\\TestMutation\\bin\\Debug\\TestMutation.exe";
string devenv =
  "C:\\Program Files (x86)\\Microsoft Visual Studio 9.0\\Common7\\IDE\\
  devenv.exe"; 
...

稍后,您将看到如何使用每个文件。 请注意,我所指向的 devenv.exe 程序与 Visual Studio 2008 相关联。 我没有对该位置进行硬编码,而是生成一个 devenv.exe 的副本,并放置到变化系统的根文件夹中。

程序继续:

List<int> positions = GetMutationPositions(originalSourceFile);
int numberMutants = 2;
...

我调用帮助程序 GetMutationPositions 方法来扫描原始源代码文件并将所有的“<”和“>”字符的字符位置存储到 List 中,然后将要创建和测试的变化数量设置为 2。

主处理循环为:

for (int i = 0; i < numberMutants; ++i) {
  Console.WriteLine("Mutant # " + i);
  int randomPosition = positions[ran.Next(0, positions.Count)];
  CreateMutantSource(originalSourceFile, randomPosition, mutatedSourceFile);

  try {
    BuildMutant(mutantProject, devenv);
    BuildTestProject(testProject, devenv);
    TestMutant(testExecutable);
  }
  catch {
    Console.WriteLine("Invalid mutant.
Aborting.");
    continue;
  }
}
...

在循环中,程序会提取字符的随机位置以便从可能位置列表进行变化,然后调用帮助程序方法来生成变化的 Class1.cs 源代码,构建相应的变化 MathLib.dll,重新构建测试工具以便使用新的变化,然后测试变化的 DLL,并希望会生成错误。 因为变化的源代码很可能是无效的,所以我将构建和测试尝试封装在 try-catch 语句中,以便可以中止对不可构建的代码的测试。

Main 方法封装为:

...
Console.WriteLine("\nMutation test run complete");
  }
  catch (Exception ex) {
    Console.WriteLine("Fatal: " + ex.Message);
  }
} // Main()

创建变化源代码

获取可能的变化位置列表的帮助程序方法是:

static List<int> GetMutationPositions(string originalSourceFile)
{
  StreamReader sr = File.OpenText(originalSourceFile);
  int ch = 0; int pos = 0;
  List<int> list = new List<int>();
  while ((ch = sr.Read()) != -1) {
    if ((char)ch == '>' || (char)ch == '<')
      list.Add(pos);
    ++pos;
  }
  sr.Close();
  return list;
}

该方法逐个字符匹配源代码,寻找大于和小于运算符,并将字符位置添加到 List 集合中。 请注意,这里介绍的超简单变化测试系统存在一项限制,即只能变化单字符的符号,如“>”或“+”,而不能变化多字符的符号,如“>=”。 实际用于变化 SUT 源代码的帮助程序方法如图 4 所示。

图 4 CreateMutantSource 方法

static void CreateMutantSource(string originalSourceFile,
  int mutatePosition, string mutatedSourceFile)
{
  FileStream ifs = new FileStream(originalSourceFile, FileMode.Open);
  StreamReader sr = new StreamReader(ifs);
  FileStream ofs = new FileStream(mutatedSourceFile, FileMode.Create);
  StreamWriter sw = new StreamWriter(ofs);
  int currPos = 0;
  int currChar;
 
  while ((currChar = sr.Read()) != -1)
  {
    if (currPos == mutatePosition)
    {
      if ((char)currChar == '<') {
        sw.Write('>');
      }
      else if ((char)currChar == '>') {
        sw.Write('<');
      }
      else sw.Write((char)currChar);
    }
    else
       sw.Write((char)currChar);

    ++currPos;
   }
 
  sw.Close(); ofs.Close();
  sr.Close(); ifs.Close();
}

CreateMutantSource 方法接受原始的源代码文件(已提前保存),还接受要变化的字符位置和生成的变化文件的名称及保存位置。 此处,我仅检查“<”和“>”字符,但您可能希望看一下其他变化。 通常,您希望变化会产生有效的源,因此您不能将“>”改成“=”。 此外,在多个位置上变化也是不可取的,因为这多个变化中可能只有一个变化会生成新的测试用例失败,从而表明测试集有效,但其实并非如此。 一些变化没有实用性(例如变化注释里的字符),另一些变化会产生无效代码(例如将移位运算符“>>””更改为“><“)。

构建和测试变化

BuildMutant 帮助程序方法是:

static void BuildMutant(string mutantSolution, string devenv)
{
  ProcessStartInfo psi =
    new ProcessStartInfo(devenv, mutantSolution + " /rebuild");
  Process p = new Process();
      
  p.StartInfo = psi; p.Start();
  while (p.HasExited == false) {
    System.Threading.Thread.Sleep(400);
    Console.WriteLine("Waiting for mutant build to complete .
. "
);
  }
  p.Close();
}

我使用 Process 对象来调用 devenv.exe 程序以重新构建 Visual Studio 解决方案,该方案中包含已变化的 Class1.cs 源代码并产生 MathLib.dll 变化。 无需参数,devenv.exe 便可启动 Visual Studio IDE,但是当参数传递后,devenv 即可用于重新构建项目或解决方案。 请注意,我使用了延时循环,每 400 毫秒暂停一次以便让 devenv.exe 有时间完成构建变化 DLL;否则,变化系统会在变化 SUT 创建之前即尝试测试它。

用于重新构建测试工具的帮助程序方法是:

static void BuildTestProject(string testProject, string devenv)
{
  ProcessStartInfo psi =
    new ProcessStartInfo(devenv, testProject + " /rebuild");
  Process p = new Process();

  p.StartInfo = psi; p.Start();
  while (p.HasExited == false) {
    System.Threading.Thread.Sleep(500);
    Console.WriteLine("Waiting for test project build to complete .
. "
);
  }
  p.Close();
}

这里的主要思路是,通过重新构建测试项目,测试工具在执行时会使用新的变化 SUT 而不使用以前使用过的变化 SUT。 如果您的变化源代码无效,则 BuildTestProject 将抛出异常。

超简单变化测试系统的最后一个部分是用于调用测试工具的帮助程序方法:

...
static void TestMutant(string testExecutable)
    {
      ProcessStartInfo psi = new ProcessStartInfo(testExecutable);
      Process p = new Process(); p.StartInfo = psi;
      p.Start();
      while (p.HasExited == false)
        System.Threading.Thread.Sleep(200);

      p.Close();
    } 

  } // class Program
} // ns Mutation

正如我前面谈到的,测试工具使用硬编码的日志文件名称和位置,但您可以将其参数化,方法是将信息作为参数传递给 TestMutant,并放在 Process 的 StartInfo 中(在此,它可被 TestMutation.exe 测试工具接受)。

实际应用的变化测试系统

变化测试从原理上讲并不复杂,但是创建一个成熟的变化测试系统需要注意的细节却很有挑战性。然而,通过尽可能简化变化测试系统,并利用 Visual Studio 和 devenv.exe,您可针对 .NET SUT 创建非常有效的变化测试系统。使用我在此处介绍的示例,您应该可以创建自己的 SUT 变化测试系统。示例变化测试系统的主要限制在于,由于该系统是基于单字符更改,因此,您不能轻松执行多字符运算符的变化,例如将“>=”更改为其求补运算符“<”。另一个限制是,该系统仅为您提供变化的字符位置,不能让您轻松诊断变化。尽管存在这些限制,但该示例系统已成功运用到许多中型软件系统中,用来衡量测试套件的有效性。

James McCaffrey博士 供职于 Volt Information Sciences, Inc.,在该公司他负责管理对华盛顿州雷蒙德市沃什湾 Microsoft 总部园区的软件工程师进行的技术培训。他参与过多项 Microsoft 产品的研发工作,包括 Internet Explorer 和 MSN Search。McCaffrey 博士是《.NET Test Automation Recipes》(Apress, 2006) 的作者,您可通过以下电子邮箱地址与他联系:jammc@microsoft.com

衷心感谢以下 Microsoft 技术专家对本文的审阅:Paul Koch、Dan LieblingShane Williams