65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 NUnit、Ruby 和 Watir 集成 ASP.NET Web 应用程序测试

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.65/5 (22投票s)

2006年1月13日

23分钟阅读

viewsIcon

104854

downloadIcon

573

在一个集成框架中,使用 Ruby 和 Watir 对 NUnit 测试进行 Web UI 测试补充。

NUnit GUI with NUnit and Ruby/Watir tests side-by-side

引言

测试 ASP.NET Web 应用程序可能是一个痛苦的过程。如果有一个单元测试框架能够以集成的方式涵盖 API 和 Web UI 测试,那岂不是很好吗?在本文中,我们将逐步介绍 NUnit 和 Ruby 单元测试的集成——不仅包括代码是如何编写的,还包括其背后的思考过程。

选择测试框架

NUnit 是一个出色的 .NET API 级别单元测试框架,它得到了广泛的认可和支持。它甚至可以通过 TestDriven.NET VS 插件与 Visual Studio 集成。作为一款免费的开源产品,它是我们 API 测试框架的明显赢家。(请参阅文末关于其他单元测试框架的部分。)

问题是它在 Web UI 方面对我们帮助不大。有一些项目,例如 NUnitAsp,可以辅助此类测试,但是当你编写自定义服务器控件时,NUnitAsp 还有很多不足之处,并且可能会导致大量工作。其他 UI 测试工具(如 Rational Robot)价格昂贵,并且可能需要大量培训才能正确使用。我们没有那么多时间或金钱。

这就是 Ruby 和 Watir 的用武之地。Ruby 是一种解释型、面向对象的脚本语言,学习和使用都非常快。它有自己的单元测试概念。Ruby 有一个名为 Watir(Web 应用程序 Ruby 测试)的库,它增强了 Ruby 单元测试,使 Web UI 测试变得超级简单。Ruby 和 Watir 都像 NUnit 一样是免费的开源产品,而且学习曲线低,它们是无与伦比的。Watir 唯一的问题是它只会在 Internet Explorer 中测试你的 Web 应用程序,但我们可以接受这一点。我们为 UI 测试选择 Ruby/Watir 解决方案。

环境设置

本文的其余部分假设您已具备

  • Visual Studio .NET(或者您可以手动编译,但我不会提供相关步骤)
  • NUnit
  • Ruby
  • Watir

我在这里不会深入介绍安装说明;在各自的网站上有大量文档可以解释这一点。

上述软件包的版本并不是特别重要。这应该适用于任何版本。为了本文的目的,我将使用 NUnit 2.1、Ruby 1.8.2 和 Watir 1.3.1,但同样,更高版本也应该没有问题。

我们将走向何方

我们有两个不同的单元测试框架——NUnit 和 Ruby——我们希望从它们那里获得集成测试和结果。假设您将让开发人员不仅编写测试,而且运行它们以确保他们在签入更改时不会破坏您的构建(您在签入代码之前确实进行了测试,对吗?),您不想浪费时间在不同的框架中运行大量测试并手动尝试汇总结果——这容易出错且耗时。测试应该易于编写,并且应该可以从一个地方运行,这样当所有的灯都亮绿灯时,我们就知道所有的测试都通过了。

我们需要将 NUnit 和 Ruby 单元测试集成到一个良好的兼容框架中。由于我们希望拥有 TestDriven.NET 提供的 Visual Studio 集成功能,并且能够使用其他基于 NUnit 的测试运行器(我喜欢在 NUnit GUI 中看到我的测试,而且 NAnt 支持 NUnit 测试也没有坏处),我们将把 Ruby/Watir 集成到 NUnit 中,而不是反过来。

运行 Ruby/Watir 测试

Ruby 单元测试非常简单。下面是一个包含单个测试的示例测试脚本

require 'test/unit'
require 'test/unit/assertions'

class RubyTest < Test::Unit::TestCase
   def test_Valid
      assert(true, 'This unit test should always pass.')
   end
end

我们注意到的关键点是

  • Ruby 单元测试库通过测试脚本顶部的 require 语句包含在内。
  • 我们定义了一个从 Ruby Test::Unit::TestCase 类派生的类。
  • 测试被定义为类中的方法,前缀为 test_ —— Ruby 测试运行器将以这种方式命名的方法识别为单元测试。
  • 断言的制作方式与 NUnit 类似。在上面的例子中,我们总是断言“true”,所以测试将始终通过。

关于 Ruby 及其单元测试框架的其他有趣之处(不一定由示例说明,但将纳入我们的集成)

  • Ruby 单元测试与 NUnit 一样,也有测试设置和拆卸的概念——如果您在测试类中定义了一个名为 setup 的方法,该方法将在每次测试执行前调用;如果您在测试类中定义了一个名为 teardown 的方法,该方法将在每次测试执行后调用。
  • Ruby 单元测试没有测试夹具设置或拆卸的概念——这与 NUnit 不同。
  • Ruby 必须从命令行运行,有点像 VBScript。它附带两个解释器——ruby.exerubyw.exeruby.exe 使用控制台窗口并输出到控制台;rubyw.exe 将运行脚本,但不提供标准输入或输出,也不显示控制台窗口。
  • 可以使用 --name 命令行参数并指定要运行的测试名称来执行单个测试。Watir 测试与标准 Ruby 单元测试没有太大区别。下面是一个 Watir 测试,它查看 Google 主页并验证页面上某处是否存在文本“Google”
require 'watir'
require 'test/unit'
require 'test/unit/assertions'
include Watir

class WatirTest < Test::Unit::TestCase
   def setup
      @ie = IE.new
      @ie.goto("http://www.google.com")
   end
   
   def test_Valid
      assert(@ie.contains_text('Google'), 
             'This unit test should always pass.')
   end
   
   def teardown
      @ie.close
   end
end

Ruby 测试和 Watir 测试之间有什么区别?

  • Watir 库除了 Ruby 单元测试库之外,还通过一个 require 语句包含在内。
  • Watir 对象命名空间通过一个 include 语句导入(类似于 C# using 指令)。
  • 在测试设置中,我们正在创建 IE 实例并导航到 Google。
  • 在测试拆卸中,我们正在关闭 IE 实例。希望是每次测试,无论通过还是失败,都能获得一个全新的浏览器实例,并且在测试运行结束时我们不会有大量遗留的打开窗口。
  • 在 Watir 测试执行中添加 -b 命令行参数将会在不显示浏览器窗口的情况下运行测试。(这会很有用。)

再次强调,我不会深入探讨 Ruby 和 Watir 测试的更详细方面——关于这一点,外面有很多示例文档。足以说明 Ruby/Watir 测试框架为我们提供了一个相当不错的实体来集成,并包括了上面提到的一些在集成过程中我们应该记住的事情。

要求

鉴于我们对 NUnit 和 Ruby 测试的了解,让我们列出我们的要求

  • 与 Ruby 的通信必须在命令行上进行。我们可能可以运行单元测试并将输出转储到文件并在那里读取输入,但这对我们帮助不大。我们还可以编写某种 Ruby 测试运行器并使用它,但那样我们还需要分发我们的自定义测试运行器。这不太好。
  • 与 NUnit 的集成应尽可能不具侵入性且易于使用。它可以采取基类、一组特殊方法或其他形式,但不应阻止我们像以前一样编写 NUnit 测试,也不应阻止我们将 NUnit 和 Ruby 测试混合在一个 NUnit 测试夹具中。
  • 由于 Ruby 测试可能需要额外的脚本或支持文件才能运行(配置文件、要包含的其他脚本库等),我们需要支持这种能力。
  • 一个 Ruby 测试应对应一个 NUnit 测试。也就是说,NUnit GUI 中的一个灯不应同时显示多个测试的结果——NUnit 测试不这样做,为什么我们的集成测试会有所不同呢?
  • 一个单元测试程序集应该可以作为一个单一实体分发——不需要分发一堆额外的 Ruby 脚本和其他支持文件。只要运行测试的人安装了 Ruby、Watir、NUnit 和我们的集成框架,他们就不需要任何其他东西。

鉴于这些要求,我们希望如何集成?

架构

我们将这样做:遵循 NUnit 模式,我们将添加一些自定义属性,表明 NUnit 测试是 Ruby 或 Watir 测试。我们将所有 Ruby/Watir 脚本及其支持文件作为资源嵌入 NUnit 测试程序集中,以方便分发。最后,我们将创建一个测试执行器,它将使用这些属性来提取脚本,启动 Ruby 解释器,执行我们的测试,解析结果,并将其转换为 NUnit 可理解的形式。

这意味着我们必须在运行时从我们的测试执行器中获取 NUnit 测试程序集中的属性信息。与其拥有一个我们必须从中派生测试夹具的基类(这相当具有侵入性,不是吗?),我们不如使用反射和遍历堆栈的能力来确定我们正在运行的测试,这样我们就能知道从哪里读取自定义属性。这使我们能够使用一种访问者模式来实现功能,这对于那些希望在不修改继承链的情况下执行集成的人来说尤其有用。相反,我们只是依附在旁边。

只要我们编写 Ruby 测试时假设任何支持文件都将与执行测试脚本位于同一文件夹中(或其子文件夹中),这将轻而易举。

自定义属性

自定义属性非常简单。我们将需要三个

  • RubyTestAttribute:类似于 NUnit 的 TestAttributeRubyTestAttribute 向我们的测试执行器指示要运行的测试是一个 Ruby 脚本。它告诉我们测试的名称以及包含该测试的脚本是哪个嵌入式资源。每个测试只应存在其中一个。
  • WatirTestAttributeRubyTestAttribute 的派生,这告诉我们该测试不仅是一个 Ruby 脚本,而且还涉及到 Watir。我们区分两者,这样我们就可以根据需要为 Watir 脚本提供额外的命令,例如一个指示是否在测试运行时显示浏览器窗口的标志。
  • RubySupportFileAttribute:告知测试执行器关于一个必须提取以支持将要执行的测试的嵌入式资源。这可以与 Ruby 或 Watir 测试一起使用,并且每个测试可能有一个以上。

编写自定义属性非常简单。RubyTestAttribute 将作用于方法并具有三个属性

  • AssemblyName:嵌入式测试脚本所在的外部程序集名称。我们将使其成为可选,但如果我们决定将 Ruby 测试分发到与包含 NUnit 测试的程序集分开的程序集中,那将非常有用。
  • EmbeddedScriptPath:程序集中嵌入式 Ruby 测试脚本资源的路径。
  • TestMethod:Ruby 脚本中与我们想要运行的单元测试相对应的方法名称。我们可以使用该信息将 --name 命令行参数传递给 Ruby 解释器,并仅运行命名测试(还记得上面说的吗?)。

去掉注释,RubyTestAttribute 看起来像这样

using System;

namespace Paraesthesia.Test.Ruby{
   [AttributeUsage(AttributeTargets.Method)]
   public class RubyTestAttribute : System.Attribute {
      private string _assemblyName = "";
      private string _embeddedScriptPath = "";
      private string _testMethod = "";

      public string AssemblyName {
         get {
            return _assemblyName;
         }
         set {
            if(value == null){
               _assemblyName = "";
            }
            else{
               _assemblyName = value;
            }
         }
      }

      public string EmbeddedScriptPath {
         get {
            return _embeddedScriptPath;
         }
      }

      public string TestMethod {
         get {
            return _testMethod;
         }
      }

      public RubyTestAttribute(string embeddedScriptPath, 
                               string testMethod) {
         // Validate parameters here -
         // code omitted from example for easier reading
         this._embeddedScriptPath = embeddedScriptPath;
         this._testMethod = testMethod;
      }

      public RubyTestAttribute(string embeddedScriptPath, 
             string testMethod, string assemblyName) :
         this(embeddedScriptPath, testMethod){
         // Validate parameters here -
         // code omitted from example for easier reading
         this._assemblyName = assemblyName;
      }
   }
}

还不错,对吧?我们有适当的属性构造函数,允许可选指定程序集名称,并且我们为每个传入参数提供了属性。很酷。WatirTestAttribute 只是从 RubyTestAttribute 派生并提供适当的构造函数。同样,我们只需要它来区分纯 Ruby 测试和 Watir 测试

using System;

namespace Paraesthesia.Test.Ruby{
   [AttributeUsage(AttributeTargets.Method)]
   public class WatirTestAttribute : RubyTestAttribute {
      public WatirTestAttribute(string embeddedScriptPath, 
                                string testMethod) :
         base(embeddedScriptPath, testMethod){}
      public WatirTestAttribute(string embeddedScriptPath, 
             string testMethod, string assemblyName) :
         base(embeddedScriptPath, testMethod, assemblyName){}
   }
}

现在,我们可以在 NUnit 测试方法上放置这些属性,以指示我们希望运行 Ruby 或 Watir 测试。我们稍后会这样做;现在这样做没有任何好处,因为我们没有任何东西知道如何处理这些属性。这就是测试执行器。

我们需要的最后一个属性是 RubySupportFileAttribute,用于告诉执行器存在需要从程序集中提取的辅助文件,以帮助我们的 Ruby/Watir 测试脚本。这看起来与 RubyTestAttribute 非常相似

using System;

namespace Paraesthesia.Test.Ruby {
   [AttributeUsage(AttributeTargets.Method, AllowMultiple=true)]
   public class RubySupportFileAttribute : Attribute {
      private string _assemblyName = "";
      private string _embeddedFilePath = "";
      private string _targetFilename = "";

      public string AssemblyName {
         get {
            return _assemblyName;
         }
         set {
            if(value == null){
               _assemblyName = "";
            }
            else{
               _assemblyName = value;
            }
         }
      }

      public string EmbeddedFilePath {
         get {
            return _embeddedFilePath;
         }
      }

      public string TargetFilename {
         get {
            return _targetFilename;
         }
      }

      public RubySupportFileAttribute(string embeddedFilePath, 
                                      string targetFilename) {
         // Validate parameters here -
         // code omitted from example for easier reading
         this._embeddedFilePath = embeddedFilePath;
         this._targetFilename = targetFilename;

      }

      public RubySupportFileAttribute(string embeddedFilePath, 
             string targetFilename, string assemblyName) :
         this(embeddedFilePath, targetFilename){
         // Validate parameters here -
         // code omitted from example for easier reading
         this._assemblyName = assemblyName;
      }
   }
}

我们仍然允许 AssemblyName 可选属性,但是我们将 EmbeddedScriptPath 替换为 EmbeddedFilePath(因为它不一定是我们要嵌入的脚本),并且我们添加了一个 TargetFilename 属性,允许我们指定一个路径(相对于执行脚本)来提取支持文件。这使我们能够拥有一个完整的文件系统层次结构,动态地相对于执行单元测试进行提取。同样,只要我们确保任何支持文件都在执行脚本的同一文件夹或以下,我们就可以了。

(为什么我们不允许文件在层次结构中高于执行脚本?我们必须将临时提取的文件系统“根”到某个地方,并且由于每个测试最终都会得到自己的一份提取文件,因此动态计算每个文件在临时位置需要去哪里以便重新创建任意文件系统层次结构变得非常痛苦。也许,我选择了简单的方法,但我不想花时间编写该代码。如果您愿意,请随意。您将在执行器中看到在哪里进行操作。)

很好,现在我们有了测试元数据属性,我们有一种方法可以将 NUnit 测试标记为 Ruby 测试,以便我们的执行器知道它需要做些什么。现在怎么办?

测试结果的数据结构

我们知道我们需要在测试执行器内部传递 Ruby 测试结果。如果能将这些数据返回给调用我们执行器的 NUnit 测试,那也会很好,这样想要在我们执行器之上构建的人就不必费力获取测试结果。Ruby 测试结果将包括

  • 运行的测试数量
  • 进行的断言数量
  • 失败的测试数量
  • 遇到的错误数量
  • 任何相关的测试输出

我们的数据类将包含所有这些信息,此外我们还将添加一个名为 Success 的便捷属性,以便我们能够快速确定测试结果是否表示测试成功(即没有失败的测试,也没有错误)。RubyTestResult 类最终看起来像这样

using System;

namespace Paraesthesia.Test.Ruby {
   public class RubyTestResult {

      private int _assertions = 0;
      private int _errors = 0;
      private int _failures = 0;
      private string _message = "";
      private int _tests = 0;

      public int Assertions {
         get {
            return _assertions;
         }
      }

      public int Errors {
         get {
            return _errors;
         }
      }

      public int Failures {
         get {
            return _failures;
         }
      }

      public string Message {
         get {
            return _message;
         }
      }

      public bool Success {
         get {
            return this.Failures == 0 && this.Errors == 0;
         }
      }

      public int Tests {
         get {
            return _tests;
         }
      }

      public RubyTestResult(int tests, int assertions, 
             int failures, int errors, string message) {
         if(tests <= 0){
            throw new ArgumentOutOfRangeException("tests",
               tests,
               "Number of tests contained in a" + 
               " RubyTestResult must be at least 1.");
         }
         this._tests = tests;

         if(assertions < 0){
            throw new ArgumentOutOfRangeException("assertions",
               assertions,
               "Number of assertions contained" + 
               " in a RubyTestResult must be at least 0.");
         }
         this._assertions = assertions;

         if(failures < 0){
            throw new ArgumentOutOfRangeException("failures",
               failures,
               "Number of failures contained in a" + 
               " RubyTestResult must be at least 0.");
         }
         this._failures = failures;

         if(errors < 0){
            throw new ArgumentOutOfRangeException("errors",
               errors,
               "Number of errors contained in a " + 
               "RubyTestResult must be at least 0.");
         }
         this._errors = errors;

         if(message != null){
            this._message = message;
         }
      }
   }
}

启动 RubyTestExecutor

由于我们将使 RubyTestExecutor 成为一种“静态访问者”,它实际上不会有任何实例方法——只有 static。将类声明为 public sealed,这样人们就不会尝试从它派生,并创建一个 private 默认构造函数,这样就不会有人尝试创建实例。哦,再添加一些有用的 using 指令,使事情变得整洁

using System;
using System.CodeDom.Compiler;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using NUnit.Framework;

我们将首先引入一个方法,在测试执行期间,我们用它将嵌入式脚本/文件从程序集中提取到临时位置。我们将该方法命名为 ExtractResourceToFile,它应该看起来像这样

private static void ExtractResourceToFile(Assembly assembly, 
        string resourcePath, string destinationPath){
   // Validate parameters here -
   // code omitted from example for easier reading

   System.IO.Stream resStream = null;
   FileStream fstm = null;
   try{
      // Get the stream from the assembly resource.
      resStream = assembly.GetManifestResourceStream(resourcePath);

      // Get a filestream to write the data to.
      fstm = new FileStream(destinationPath, FileMode.CreateNew, 
                 FileAccess.Write, FileShare.ReadWrite);

      // Initialize properties for reading stream data
      long numBytesToRead = resStream.Length;
      int numBytesRead = 0;
      int bufferSize = 1024;
      byte[] bytes = new byte[bufferSize];

      // Read the file from the resource
      // stream and write to the file system
      while(numBytesToRead > 0){
         int numReadBytes = resStream.Read(bytes, 0, bufferSize);
         if(numReadBytes == 0){
            break;
         }
         if(numReadBytes < bufferSize){
            fstm.Write(bytes, 0, numReadBytes);
         }
         else{
            fstm.Write(bytes, 0, bufferSize);
         }
         numBytesRead += numReadBytes;
         numBytesToRead -= numReadBytes;
      }
      fstm.Flush();
   }
   catch{
      Console.Error.WriteLine(
         "Unable to write resource [{0}] from" + 
         " assembly [{1}] to destination [{2}].",
         resourcePath,
         assembly.FullName,
         destinationPath);
      throw;
   }
   finally{
      // Close the resource stream
      if(resStream != null){
         resStream.Close();
      }

      // Close the file
      if(fstm != null){
         fstm.Close();
      }
   }
}

有了这个,我们可以指定一个程序集、一个嵌入式资源路径和一个资源应该转储到的位置,然后资源将被提取。我们向控制台窗口写入错误是可以的,因为 NUnit 测试运行器会根据需要适当地重定向该输出。例如,NUnit GUI 有一个专门用于 Console.Error 的特殊窗口。

我们想要的最后一项便利是测试框架的一些配置。我们可以利用 .NET 内置的应用程序配置文件功能,所以如果有人指定了一些特定的 appSettings 值,我们将能够相应地执行操作。我们将添加一些将从配置文件中读取的属性,指示要查找的 appSettings 键的常量,以及一个将从 appSettings 值中解析布尔值的便捷方法。它将看起来像这样

// App settings key for whether to delete
// temp files when the test is finished.
const string SETTINGS_DELETEFILESWHENFINISHED = 
                 "DeleteTempFilesWhenFinished";

// App settings key for display/hide
// the browser window in a WATIR test.
const string SETTINGS_SHOWBROWSERWINDOW = "ShowBrowserWindow";

// App settings key for failure output stream.
const string SETTINGS_FAILURESTREAM = "FailureStream";

// App settings key for success output stream.
const string SETTINGS_SUCCESSSTREAM = "SuccessStream";

// App settings key for success output stream.
const string SETTINGS_FAILMESSAGEFROMTESTOUTPUT = 
                     "FailMessageFromTestOutput";

public static bool FailMessageFromTestOutput{
   get {
      return ParseAppSettingsBoolean(
             SETTINGS_FAILMESSAGEFROMTESTOUTPUT, true);
   }
}

public static bool ShowBrowserWindow{
   get {
      return ParseAppSettingsBoolean(
             SETTINGS_SHOWBROWSERWINDOW, false);
   }
}

public static bool DeleteTempFilesWhenFinished{
   get {
      return ParseAppSettingsBoolean(
             SETTINGS_DELETEFILESWHENFINISHED, true);
   }
}

public static TextWriter FailureStream{
   get {
      string streamName = 
        System.Configuration.ConfigurationSettings.
        AppSettings[SETTINGS_FAILURESTREAM];
      TextWriter writer = null;
      switch(streamName){
         case "Error":
            writer = Console.Error;
            break;
         case "Out":
            writer = Console.Out;
            break;
         case "None":
         default:
            writer = null;
            break;
      }
      return writer;
   }
}

public static TextWriter SuccessStream{
   get {
      string streamName = 
        System.Configuration.ConfigurationSettings.
        AppSettings[SETTINGS_SUCCESSSTREAM];
      TextWriter writer = null;
      switch(streamName){
         case "Error":
            writer = Console.Error;
            break;
         case "None":
            writer = null;
            break;
         case "Out":
         default:
            writer = Console.Out;
            break;
      }
      return writer;
   }
}

private static bool ParseAppSettingsBoolean(string appSettingsKey, 
                                            bool defaultValue){
   bool retVal = defaultValue;
   string retValStr = 
     System.Configuration.ConfigurationSettings.
     AppSettings[appSettingsKey];
   if(retValStr != null && retValStr != ""){
      try{
         retVal = bool.Parse(retValStr);
      }
      catch{
         retVal = defaultValue;
      }
   }
   return retVal;
}

我们是如何知道我们需要所有这些配置值的?实际上,当我第一次编写它时,我并不知道。一旦我开始使用执行器,我发现将上述值可配置在调试测试和确保测试输出针对给定测试运行器正确渲染方面非常有帮助。默认值应该适用于大多数情况(实际上您不需要提供任何配置——所有东西都有默认值),但是如果您需要更改执行器的一些行为,您可以做到。

我们还将添加一个便利方法,该方法将获取一个方法描述并从中获取 RubyTestAttribute。这将有助于我们稍后获取属性,并且还将帮助我们验证某人是否试图从同一方法运行两个测试(这是不合法的,但我无法通过编程强制 RubyTestAttributeWatirTestAttribute 互斥)。该方法将如下所示

private static RubyTestAttribute GetRubyTestAttribute(MethodBase method){
   RubyTestAttribute[] rubyTests = (RubyTestAttribute[])
         method.GetCustomAttributes(typeof(RubyTestAttribute), true);
   if(rubyTests == null || rubyTests.Length == 0){
      return null;
   }
   if(rubyTests.Length > 1){
      throw new NotSupportedException("Only one" + 
            " RubyTestAttribute per method is allowed.");
   }
   RubyTestAttribute rubyTest = rubyTests[0];
   return rubyTest;
}

现在我们可以开始做一些更令人兴奋的事情了。让我们编写一个方法,它实际启动一个 Ruby 进程,执行测试,并将结果解析成我们的 RubyTestResult 对象之一。当我们这样做时,我们将使用以下事实

  • 每个测试都将使用命令运行:ruby.exe "scriptname" --name=testmethodname
  • Watir 脚本可能希望在命令行上添加一个 -b 以在不显示浏览器窗口的情况下运行测试。
  • 测试结果将从控制台输出中解析。Ruby 测试结果的形式为 X tests, Y assertions, Z failures, Q errors,其中 XYZQ 都是整数,因此我们将使用正则表达式对控制台输出进行处理,以获取该行并将结果解析成我们可以理解的内容。

将其设置为 private,这样类的用户就不会尝试直接调用它。一切准备就绪后,我们的方法将如下所示

private static RubyTestResult RunTest(string scriptFilename, 
               RubyTestAttribute attrib, string workingDirectory){
   // Build the basic command line
   string command = "ruby.exe";
   string arguments = String.Format("\"{0}\" --name={1}", 
                      scriptFilename, attrib.TestMethod);

   // WATIR-specific options
   if(attrib is WatirTestAttribute){
      // Determine if we show/hide the browser window
      bool showBrowserWindow = RubyTestExecutor.ShowBrowserWindow;
      if(!showBrowserWindow){
         arguments += " -b";
      }
   }

   // Test execution
   RubyTestResult results = null;
   using (Process proc = new Process()) {
      // Create the process
      proc.StartInfo.UseShellExecute = false;
      proc.StartInfo.RedirectStandardOutput = true;
      proc.StartInfo.CreateNoWindow = true;
      proc.StartInfo.FileName = command;
      proc.StartInfo.Arguments = arguments;
      proc.StartInfo.WorkingDirectory = workingDirectory;

      // Execute the process
      proc.Start();
      proc.WaitForExit();

      // Get the output
      string output = proc.StandardOutput.ReadToEnd();

      // Clean up the process
      proc.Close();

      // Parse the results
      RegexOptions options = RegexOptions.IgnoreCase | RegexOptions.Multiline;
      Regex regex = new Regex(
         @"(\d+)\s*tests[^\d]*(\d+)\s*assertions[^\" + 
         @"d]*(\d+)\s*failures[^\d]*(\d+)\s*errors",
         options);
      Match match = regex.Match(output);
      if(match != null && match.Success){
         int tests = Convert.ToInt32(match.Groups[1].Value,
            System.Globalization.CultureInfo.CurrentCulture);
         int assertions = Convert.ToInt32(match.Groups[2].Value,
            System.Globalization.CultureInfo.CurrentCulture);
         int failures = Convert.ToInt32(match.Groups[3].Value,
            System.Globalization.CultureInfo.CurrentCulture);
         int errors = Convert.ToInt32(match.Groups[4].Value,
            System.Globalization.CultureInfo.CurrentCulture);
         results = new RubyTestResult(tests, assertions, 
                                      failures, errors, output);
      }
      else{
         results = new RubyTestResult(1, 0, 0, 1,
            "*** Unable to parse output from Ruby test ***\n" + output);
      }
   }

   return results;
}

我们传入已提取脚本的完整路径、测试的 RubyTestAttribute(它包含运行测试所需的相关数据,并有助于确定测试是纯 Ruby 还是涉及 Watir)以及工作目录。根据这些信息,我们创建执行 Ruby 的命令行,启动 Ruby 进程,然后将结果解析到我们的 RubyTestResults 结构中,然后返回。

现在是重点——驱动获取属性信息、提取文件、运行测试和处理结果的方法。我们将它命名为 ExecuteTest,并将其设为 public——这是我们的 NUnit 测试将调用以启动 Ruby 测试并获取结果的方法。ExecuteTest 将接受两个参数——一个指示我们是否要显示输出(有些人可能不想看到来自 Ruby 的消息),另一个指示我们是否应该根据 Ruby 测试输出进行 NUnit 断言(这就是我们将 Ruby 结果转换为 NUnit 结果的方式)。我们还将提供一个不带任何参数的重载,以便希望使用默认行为的人可以使用它

public static RubyTestResult ExecuteTest(){
   return ExecuteTest(true, true);
}

public static RubyTestResult ExecuteTest(bool makeAssertions, 
                                         bool displayOutput){
   // Our code will go here
}

酷。让我们编写这个东西。

ExecuteTest 方法

执行测试时,我们首先需要做的就是找出哪个方法正在调用我们的测试执行器。幸运的是,我们可以使用 StackTrace 类来做到这一点。我们将获取调用堆栈,然后向后遍历,直到我们走出 RubyTestExecutor 的调用。我们遇到的第一个方法是调用 RubyTestExecutor 的方法,这就是我们想要从中获取测试属性的方法。执行此操作的代码如下所示

// Get the RubyTest attribute on the calling method
StackTrace trace = new StackTrace();
MethodBase method = null;
for(int i = 0; i < trace.FrameCount; i++){
   MethodBase frameMethod = trace.GetFrame(i).GetMethod();
   if(frameMethod.ReflectedType != typeof(RubyTestExecutor)){
      method = frameMethod;
      break;
   }
}
RubyTestAttribute rubyTest = GetRubyTestAttribute(method);
if(rubyTest == null){
   Assert.Fail("GetResultsFromRuby called from a method" + 
               " that does not have the [RubyTest]" + 
               " attribute in place.");
   return null;
}

为什么我们不能只在调用堆栈上向上走一步然后停止呢?我们有那个无参数重载,并且在我们获取堆栈时,如果调用者使用了无参数重载,向上走一步就会将我们定位到该重载,而不是调用 RubyTestExecutor 的方法。如果添加其他重载,我们只需遍历堆栈,直到我们在某个调用代码中。

这工作得很好,但这有点阻止你拥有一个真正复杂的单元测试场景,你可能会尝试定义一个调用 RubyTestExecutor 的中心方法,但它没有关联的实际 RubyTestAttribute。不过,我还没有真正找到这种场景的用例,所以我们暂时这样处理。这真的不是什么限制。

现在,我们需要将 Ruby 脚本和任何关联的支持文件提取到临时位置。TempFileCollection 类将在这里帮助我们,它允许我们向集合中添加任意数量的临时文件,并在我们完成时自动删除所有这些文件。基本上,在运行测试之前,我们要做的第一件事是创建临时文件集合,而最后一件事是删除它们(除非另有配置)。所以,获取指示是否删除文件的配置设置,创建临时文件集合,并创建一个临时文件夹来存储所有临时文件。最后,清理干净。

// Get the configuration setting indicating
// if temp files should be deleted
bool deleteFilesWhenFinished = 
     RubyTestExecutor.DeleteTempFilesWhenFinished;

// Extract files and run the test
TempFileCollection tempFiles = null;
try{
   // Prepare for temporary file extraction
   tempFiles = new TempFileCollection();
   tempFiles.KeepFiles = !deleteFilesWhenFinished;
   if(!Directory.Exists(tempFiles.BasePath)){
      Directory.CreateDirectory(tempFiles.BasePath);
   }

   // Add the rest of the method code here
}
finally{
   // Delete any temporary files
   if(tempFiles != null){
      tempFiles.Delete();
      if(Directory.Exists(tempFiles.BasePath) && 
                          deleteFilesWhenFinished){
         Directory.Delete(tempFiles.BasePath, true);
      }
      tempFiles = null;
   }
}

看起来不错。我们将把其余代码添加到上面提到的位置。首先,让我们提取 Ruby 测试脚本。我们将它提取到一个固定的文件名,这样就不会混淆脚本应该叫什么。由于它是最终将运行的脚本,只要任何支持文件命名正确,脚本名称是什么都应该无关紧要。

// Extract the Ruby test
string rubyScriptPath = Path.GetFullPath(
       Path.Combine(tempFiles.BasePath, "__RubyTestScript.rb"));
Assembly scriptAssembly = null;
if(rubyTest.AssemblyName != ""){
   try{
      scriptAssembly = Assembly.Load(rubyTest.AssemblyName);
   }
   catch{
      Console.Error.WriteLine("Error loading assembly [{0}].", 
                              rubyTest.AssemblyName);
      throw;
   }
}
else{
   scriptAssembly = method.ReflectedType.Assembly;
}
ExtractResourceToFile(scriptAssembly, 
       rubyTest.EmbeddedScriptPath, rubyScriptPath);
if(!File.Exists(rubyScriptPath)){
   throw new FileNotFoundException("Error extracting" + 
         " Ruby test script to file.", rubyScriptPath);
}
tempFiles.AddFile(rubyScriptPath, !deleteFilesWhenFinished);

Ruby 脚本已从相应的程序集中提取,放置在临时目录中,并添加到我们的 TempFileCollection 中。让我们对支持文件也这样做

// Extract support files
RubySupportFileAttribute[] supportFiles =
   (RubySupportFileAttribute[])
   method.GetCustomAttributes(
   typeof(RubySupportFileAttribute), true);

if(supportFiles != null){
   foreach(RubySupportFileAttribute supportFile in supportFiles){
      // Calculate the location for the support file
      string supportFilePath = Path.GetFullPath(
        Path.Combine(tempFiles.BasePath, supportFile.TargetFilename));
      string supportFileDirectory = Path.GetDirectoryName(supportFilePath);

      // Ensure the location is valid
      if(supportFileDirectory.IndexOf(tempFiles.BasePath) != 0){
         throw new ArgumentOutOfRangeException("TargetFilename",
            supportFile.TargetFilename,
            "Target location must be at or below" + 
            " the location of the extracted Ruby script.");
      }

      // Create any missing folders
      if(!Directory.Exists(supportFileDirectory)){
         Directory.CreateDirectory(supportFileDirectory);
      }

      // Get the assembly the support file is in
      Assembly fileAssembly = null;
      if(supportFile.AssemblyName != ""){
         try{
            fileAssembly = Assembly.Load(supportFile.AssemblyName);
         }
         catch{
            Console.Error.WriteLine("Error loading" + 
                    " assembly [{0}].", supportFile.AssemblyName);
            throw;
         }
      }
      else{
         fileAssembly = method.ReflectedType.Assembly;
      }

      // Extract the support file
      ExtractResourceToFile(fileAssembly, 
             supportFile.EmbeddedFilePath, supportFilePath);
      tempFiles.AddFile(supportFilePath, !deleteFilesWhenFinished);
   }
}

请注意,我们不仅在提取支持文件,还在我们前进的过程中创建任何必要的目录结构。还记得吗,我们决定任何支持文件都必须与 Ruby 测试脚本位于同一文件夹或其下方?这个决定在这里帮助了我们——它使临时文件层次结构的布局变得更加容易。

我们已经提取了脚本,提取了支持文件,现在让我们运行测试

// Run test
RubyTestResult result = RunTest(rubyScriptPath, 
                        rubyTest, tempFiles.BasePath);

没有比这更容易的了。我们现在有了测试输出,让我们构建并显示一些结果。在 RubyTestExecutor 中添加一个常量,它将作为一种“分隔符”,将在结果中显示。我们在构建输出时将需要它

const string STR_RESULTS_DIVIDER = "\n----------\n";

现在,回到我们离开的 ExecuteTest 方法,让我们构建一些要显示的结果

// Create the test description string
string scriptDesc = String.Format("Script [{0}]; Test [{1}]", 
                    rubyTest.EmbeddedScriptPath, rubyTest.TestMethod);

// Write output
if(displayOutput && result != null && result.Message != ""){
   System.IO.TextWriter output = null;
   string successMsg = "";

   if(!result.Success){
      output = RubyTestExecutor.FailureStream;
      successMsg = "FAILED";
   }
   else{
      output = RubyTestExecutor.SuccessStream;
      successMsg = "SUCCESS";
   }
   if(output != null){
      output.WriteLine("{0}: {1}", scriptDesc, successMsg);
      output.WriteLine(result.Message);
      output.WriteLine(STR_RESULTS_DIVIDER);
   }
}

我们构建这个“测试描述字符串”是因为它将帮助我们不仅显示输出,还进行任何断言——这将让您知道哪个脚本和哪个测试失败了。接下来,假设我们已告知执行器显示输出(ExecuteTest 方法的一个参数),我们抓取正确的流(使用我们之前设置的配置属性),并转储成功消息以及任何相关的 Ruby 测试消息。

我们将做的最后一件事是,在 NUnit 中进行任何我们需要的断言(基于 ExecuteTest 的参数),并返回测试结果

// Make assertions
if(makeAssertions){
   if(result == null){
      Assert.Fail("Ruby test result not" + 
                  " correctly returned from test execution.");
   }
   else{
      string failMsg = String.Format("{0}: FAILED", scriptDesc);
      if(RubyTestExecutor.FailMessageFromTestOutput){
         failMsg += result.Message + STR_RESULTS_DIVIDER;
      }
      Assert.IsTrue(result.Success, failMsg);
   }
}

// Return results
return result;

这就是我们让 NUnit 中的红灯或绿灯与 Ruby 测试的成功或失败相对应的方式。在这种情况下,如果我们没有从测试中获得任何结果,我们就会失败,否则我们就会构建一个合理的失败消息,并断言 Ruby 测试的成功。

就是这样!现在,是时候使用 RubyTestExecutor 了。

使用 RubyTestExecutor

既然您已经编写并构建了 RubyTestExecutor,那么让我们来使用它。

您首先需要做的是编写您的 Ruby/Watir 测试脚本。确保它们在独立环境中运行——这个集成解决方案允许我们使用可以独立运行的相同脚本,而无需进行任何修改。您可能还需要创建一些使用支持文件的测试。您的脚本层次结构可能如下所示(请注意,脚本位于层次结构的顶部,支持文件位于同一文件夹或其下方)

Explorer view of the script file hierarchy

在我的例子中,我有一些脚本和一些将要使用的支持文件。

我的测试脚本与我们之前编写的脚本非常相似,但我添加了一个使用支持文件的测试

def test_RubySupportFile
   assert(File.exist?("supportfile.txt"), 
          "supportfile.txt does not exist.")
   assert(File.exist?("SubFolder1\\supportfile1.txt"), 
          "supportfile1.txt does not exist.")
   assert(File.exist?("SubFolder1\\SubFolder2\\supportfile2.txt"), 
          "supportfile2.txt does not exist.")
end

该测试只是确保支持文件存在于文件夹层次结构中——在实际测试中,您可能会使用这些支持文件来读取数据以供测试使用。就我们的目的而言,我们只是想确保支持文件在我们的集成框架中正确提取。

一旦 Ruby 测试运行符合您的要求,将脚本和支持文件作为嵌入式资源添加到您的 NUnit 项目中。此外,添加对包含 RubyTestExecutor 的程序集的引用。它最终会看起来像这样

.csproj view of the embedded scripts

最后,在您的 NUnit 测试夹具中,添加使用我们创建的自定义属性的测试方法,并调用 ExecuteTest 方法

using System;
using NUnit.Framework;
using RTE = Paraesthesia.Test.Ruby.RubyTestExecutor;

namespace Paraesthesia.Test.Ruby.Test {
   [TestFixture]
   public class RubyTestExecutor {
      [Test(Description="Verifies you may 
                         run standard NUnit-only tests.")]
      public void NUnitOnly_NoRubyAttrib(){
         Assert.IsTrue(true, 
           "This NUnit-only test should always pass.");
      }

      [RubyTest("Paraesthesia.Test.Ruby.Test.
                 Scripts.RubyTest.rb", "test_Valid")]
      [Test(Description="Verifies a valid Ruby test 
                         will execute and allow success.")]
      public void RubyTest_Valid(){
         RTE.ExecuteTest();
      }

      [WatirTest("Paraesthesia.Test.Ruby.Test.
                  Scripts.WatirTest.rb", "test_Valid")]
      [Test(Description="Verifies a valid WATIR test 
                         will execute and allow success.")]
      public void WatirTest_Valid(){
         RTE.ExecuteTest();
      }

      [RubySupportFile("Paraesthesia.Test.Ruby.
                        Test.Scripts.supportfile.txt",
         "supportfile.txt")]
      [RubySupportFile("Paraesthesia.Test.Ruby.Test.
                        Scripts.SubFolder1.supportfile1.txt",
         @"SubFolder1\supportfile1.txt")]
      [RubySupportFile("Paraesthesia.Test.Ruby.Test.
                        Scripts.SubFolder1.SubFolder2.supportfile2.txt",
         @"SubFolder1\SubFolder2\supportfile2.txt")]
      [RubyTest("Paraesthesia.Test.Ruby.Test.Scripts.RubyTest.rb",
         "test_RubySupportFile")]
      [Test(Description="Verifies Ruby support 
                         files can correctly be extracted.")]
      public void RubySupportFile_Valid(){
         RTE.ExecuteTest();
      }
   }
}

在此示例中,您可以看到我们有一个仅限 NUnit 的测试、两个 Ruby 测试和一个 Watir 测试,它们都并排运行。Ruby/Watir 测试中唯一的内容是对 RubyTestExecutor.ExecuteTest() 方法的调用。在 Ruby/Watir 测试上,我们根据需要放置了自定义属性,以将 NUnit 测试与相应的 Ruby/Watir 测试关联起来,并解释支持文件应该放在哪里。我们一次性投入编写此集成代码,构建 NUnit 程序集,然后我们就可以在 NUnit 测试运行器中并排运行所有测试

NUnit GUI with NUnit and Ruby/Watir tests side-by-side

瞧!NUnit 并排运行原生测试和 Ruby/Watir 测试!

最后润色

我为 RubyTestExecutor 做了两件额外的事情,使其更易于使用:我用密钥对程序集进行了签名,以便可以将其添加到全局程序集缓存中,并添加了一个安装程序项目,该项目将程序集安装到特定版本文件夹中,将其添加到 GAC,并在 Visual Studio 程序集列表中添加一个链接(因此您可以选择“添加引用”并在可用程序集列表中看到 RubyTestExecutor 程序集,而无需在文件系统中浏览)。我不会详细介绍具体步骤——请查看随附的包以获取最终产品。

软件包内容

附带的源代码包括

  • RubyTestExecutor 和所有必需的自定义属性,附带完整的 XML 文档注释以说明用法。
  • 用于对程序集进行签名的强名称密钥,以便将其放入 GAC
  • 一个单元测试项目,其中包括示例 Ruby 和 Watir 脚本,以确保 RubyTestExecutor 正常工作。
  • 一个安装程序项目,将安装 RubyTestExecutor 程序集,在 GAC 中注册,并将其添加到 VS.NET 程序集列表中。

后续步骤

如何才能做得更好?您可以...

  • 创建宏或 VS.NET 插件,以帮助生成与 Ruby/Watir 测试对应的 NUnit 测试。
  • 允许在 NUnit 测试夹具上设置支持文件或脚本信息,以通过更大的测试夹具减少冗余指定属性的数量。
  • 扩展支持文件概念,允许支持文件位于文件系统层次结构中测试脚本之上。

当然,可能性是无限的,但您这里拥有的至少可以让您开始。

其他单元测试框架

在撰写本文并收到一些反馈后,事实证明,这种机制应该适用于任何与 NUnit 类似功能的单元测试框架。例如,Visual Studio 2005 单元测试框架也可以使用,只需在您的单元测试上使用适当的命名空间/属性,如下所示

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using RTE = Paraesthesia.Test.Ruby.RubyTestExecutor;

namespace Paraesthesia.Test.Ruby.Test {
   [TestClass]
   public class RubyTestExecutor {
      [TestMethod]
      public void VS2005Only_NoRubyAttrib(){
         Assert.IsTrue(true, "This VS2005-only test should always pass.");
      }

      [WatirTest("Paraesthesia.Test.Ruby.Test.Scripts.WatirTest.rb", "test_Valid")]
      [TestMethod]
      public void WatirTest_Valid(){
         RTE.ExecuteTest();
      }
   }
}

结论

希望这能帮助那些面临自动化 Web UI 测试问题的人。我也希望您不仅学会了如何进行集成,还学会了为什么以这种方式进行集成。

测试愉快!

历史

  • 2006/01/13:首次发布。
  • 2006/02/06:根据 Howard van Rooijen 的意见添加了“其他单元测试框架”部分。
© . All rights reserved.