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

日志测试运行程序应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.31/5 (4投票s)

2007年11月14日

11分钟阅读

viewsIcon

34732

downloadIcon

454

一个符合 NUnit 语法的日志测试运行器,用于执行与 WatiN(或其他任何内容)的自动化集成/验收测试

引言

我目前正在为一款 Web 应用程序构建一套自动化验收测试。过去,我使用过 Watir(发音为“water”,而不是“waiter”),这是一个可以驱动 Internet Explorer 实例的框架。由于我们是微软体系公司,我现在已经转向 WatiN,它是 Watir 的 .NET 移植版。这些框架的一个缺点是它们不提供执行和报告测试的机制。相反,它们指示您使用单元测试运行器和框架来执行测试。不幸的是,验收测试的需求与单元测试的需求不同。

当前的 xUnit 测试运行器未能满足验收测试运行器的需求。为了弥合这一差距,我构建了这个日志测试运行器。它被编写成符合 NUnit 语法,因此您可以利用您在测试资产上的现有投入,并且您将使用熟悉的语法。如果感兴趣的人足够多,我将将其发展成一个完整的开源项目。

xUnit 需求与验收测试需求之间的鸿沟

单元测试自动化已经被充分理解。它已经存在了近二十年,尽管直到 eXtreme Programming 的兴起以及 Kent Beck 开发 JUnit 之后才得到广泛采用。如果您想了解更多,可以阅读 Kent 关于测试的原始文章。我还偶然发现了另一篇文章,Andrew Shebanow 的《测试框架简史》,其中声称一些单元测试自动化工作实际上早于 sUnit。但总的来说,我接触到的大多数人认为 JUnit 是我们的起点。

设计良好的 xUnit 测试应遵循以下原则

  • 测试应隔离其正在测试的内容
  • 测试应是原子的:要么完全失败,要么完全成功
  • 测试应独立:每个测试都控制其所有先决条件,并且可以独立运行
  • 测试应是可重复的:第二次或第三次运行应产生与第一次运行相同的结果
  • 测试应自行清理:保持系统原样

验收测试自动化(以及集成测试)的成熟度不高,尤其是对于 Web 应用程序。像 FITFITNesse、Watir 和 Selenium 这样的框架才出现了几年。关于如何执行验收测试,有大量的建议,但没有达成共识。可以参考 Watir 的建议。他们提供了将输出写入 Excel、控制台或使用单元测试运行器的示例。

根据其性质,验收测试有很多前置条件和后置条件。通常,这正是您所测试的内容。它们设置成本高昂且耗时。此外,什么是验收可能并不总是清楚。这是我列出的验收测试应遵循的原则

  • 测试应关注其试图证明验收的功能/故事
  • 测试应具有弹性:测试某个方面失败仍应允许整个测试执行
  • 测试应独立:每个测试都控制其所有先决条件,并且可以独立运行
  • 测试应是可重复的:第二次或第三次运行应产生与第一次运行相同的结果
  • 测试应自行清理:保持系统原样

正如您所见,需求是相似的,但并不相同。最后三点对单元测试和验收测试都是相同的,但前两点有所不同。单元测试的一个目的是准确指出代码的失败之处。为此,任何测试中的失败都是完全失败。另一方面,验收测试正在测试主要功能和交互点。您可能还在测试主要节点的同时检查许多、许多次要项目。仅仅因为一个次要项目失败,并不意味着主要功能未能正确运行。

示例:您有一个活动注册表单,并想编写一个测试来确认用户注册后,他们就存在于系统中。测试的核心是将数据输入屏幕,然后导航到注册者列表页面以确认用户存在。此屏幕可能位于您的应用程序的四个或五个屏幕之后,经过登录屏幕。每次测试运行时,都必须执行这些之前的步骤,因此设置测试的成本很高。

在最初构建测试后,您会发现您还想测试字段验证。在单元测试中,您会编写一个新的测试。但是,对于验收测试,这更像是您想要“在进行中”进行测试的内容。由于您已经在编辑屏幕中,并且已经花费成本登录并导航到正确的屏幕,因此这是执行这些测试的好时机。当您测试 20 个字段中的每一个时,您会发现其中一半的字段未正确验证。要使用单元测试运行器发现这一点,至少需要运行 10 次。日志测试运行器(又名 LogRunner)的目的就在于此。

枯燥的部分已经结束。接下来是代码。

示例测试套件

我们将构建一些简单的示例来展示 LogRunner 的实际应用。我们的示例将执行两个测试:在 Google 中搜索结果,以及浏览 MSDN 网站。

第一步:安装框架

第二步:设置项目

在 Visual Studio 2005 中创建一个新的 C# 类库项目。将其命名为 MyWatinSample。添加对 nunit.framework.dllWatiN.Core.dll 的引用。这些文件将位于它们各自安装文件夹的 bin 目录中。如果您对代码有洁癖,也可以删除 Visual Studio 添加的 System.DataSystem.Xml 引用。

第三步:创建测试夹具类

向您的项目添加名为 GoogleTest.csEntertainmentWeeklyTest.cs 的类文件。我最初是从测试 MSDN 网站开始的,但它非常慢,并且会做一些事情阻止 IE 完成页面加载,所以我改用 EW 进行了示例。这些是您的两个测试夹具。以下是每个文件的代码。测试可以更好地重构,但没有基类,意图会更清晰一些。我将让您自己闲暇时进行检查。

GoogleTest 源代码列表

using System;using NUnit.Framework;
using WatiN.Core;

namespace MyWatinSample
{
    [TestFixture]
    public class GoogleTest
    {
        #region Property to web browser instance

        private IE _browser = null;

        /// <summary>
        /// Hook to our browser instance
        /// </summary>
        public IE Browser
        {
            get
            {
                if (_browser == null)
                {
                    _browser = new IE();
                }
                return _browser;
            }
            set { _browser = value; }
        }
        #endregion

        #region Fixture setup and teardown
        [TestFixtureSetUp]
        public void SetupTestFixture()
        {
            //we lazy load the browser, so it is not in setup
        }

        [TestFixtureTearDown]
        public void TearDownTestFixture()
        {
            Browser.Close();
            Browser = null;
        }
        #endregion

        [Test]
        public void SearchForWatinHome()
        {
            string watinSearch = "WatiN Test";
            string watinHome = "WatiN Home";
            string watinURL = "http://watin.sourceforge.NET/";

            Browser.GoTo("http://www.google.com");
            //Assert we are at google
            Assert.IsTrue(Browser.ContainsText("Google"), 
                "Google page does not contain Google Name");

            //Add our search term to search box and search
            Browser.TextField(Find.ByName("q")).Value = watinSearch;
            Browser.Button(Find.ByValue("Google Search")).Click();

            //check for result
            Assert.IsTrue(Browser.ContainsText(watinHome), 
                "Search result did not find " + watinHome);
            Browser.Link(Find.ByText(watinHome)).Click();

            //validate we're on the watin sourceforge page
            Assert.AreEqual(watinURL, Browser.Url, 
                "WatiN Home not at sourceforge URL");
        }
    }
}

EntertainmentWeeklyTest 源代码列表

using System;
using WatiN.Core;

using NUnit.Framework;

namespace MyWatinSample
{
    [TestFixture]
    public class EntertainmentWeeklyTest
    {
        const string ewHomeUrl = "http://www.ew.com/ew";

        #region Property to web browser instance

        private IE _browser = null;

        /// <summary>
        /// Hook to our browser instance
        /// </summary>
        public IE Browser
        {
            get
            {
                if (_browser == null)
                {
                    _browser = new IE();
                }
                return _browser;
            }
            set { _browser = value; }
        }
        #endregion

        #region Fixture setup and teardown
        [TestFixtureSetUp]
        public void SetupTestFixture()
        {
            //we lazy load the browser, so it is not in setup
        }

        [TestFixtureTearDown]
        public void TearDownTestFixture()
        {
            Browser.Close();
            Browser = null;
        }
        #endregion

        #region Test setup and teardown
        [SetUp]
        public void TestSetup()
        {
            if (Browser.Url != ewHomeUrl)
            {
                Browser.GoTo(ewHomeUrl);
            }
        }
        [TearDown]
        public void TestTeardown()
        {
        }
        #endregion

        [Test]
        public void EWMoviesTest()
        {
            Assert.IsTrue(Browser.ContainsText("Entertainment Weekly"), 
                "Entertainment Weekly does not " + 
                "state its name on home page.");
            Assert.IsTrue(Browser.ContainsText("Movies"), 
                "Movie test not found on home page");
            Browser.Link(Find.ByText("Movies")).Click();
            Assert.IsTrue(Browser.ContainsText("Upcoming Movies"), 
                "EW Movie page does not announce upcoming movies.");
        }
        [Test]
        public void ChrisColeMoviesTest()
        {
            Assert.IsTrue(Browser.ContainsText("Entertainment Weekly"), 
                "Entertainment Weekly does " + 
                "not state its name on home page.");
            Assert.IsTrue(Browser.ContainsText("Movies"), 
                "Movie test not found on home page");
            Browser.Link(Find.ByText("Movies")).Click();
            Assert.IsTrue(Browser.ContainsText("Chris Cole"), 
                "Entertainment Weekly does not have Chris Cole's new " +
                "indie film listed on their movie site.");
        }

        [Test]
        public void BriteySpearsTest()
        {
            Assert.IsTrue(Browser.ContainsText("Britey Spears"), 
                "Entertainment Weekly does not " + 
                "have anything about Britney" +
                " Spears on its home page.");
            //search for Britney
            Browser.TextField("searchbox").Value = "Britney Spears";
            Browser.Button("btn_search").Click();

            Assert.IsTrue(Browser.ContainsText("All about"), 
                "EW does not talk all about Britney Spears");

            Browser.Link(Find.ByText("Britney Spears")).Click();
            Assert.IsTrue((Browser.Title.IndexOf("Britey Spears") > -1), 
                "About Britney page mis-titled");
        }
    }
}

现在我们有了可以在 NUnit 运行器中运行的示例。一个在 Google 中搜索 WatiN 主页,另一个浏览 MSDN 的一些页面。提供的源代码包含一个用于执行测试的 NUnit 项目。您必须在单个apartment线程上运行它。示例代码包含一个适当的配置文件来执行此操作。

LogRunner 代码

LogRunner 被设计成与 NUnit 语法兼容。做出此设计决策是为了能够使用 NUnit 运行器(控制台或 GUI)或 LogRunner 来开发和运行测试。我首次应用此技术是在一个烟雾测试中,该测试可以编译成验收测试套件,或者作为一个独立的控制台应用程序来对已部署的应用程序进行烟雾测试。编译模式通过编译开关设置,该开关会根据您的目标替换 using XXX 引用。LogRunner 是对该原始烟雾测试运行器的改进。

NUnit(以及大多数其他 .NET xUnit 框架)使用自定义属性来定义测试夹具和测试套件的各个方面。我们的日志运行器需要定义和读取相同的自定义属性。此外,我们还需要以一种记录结果并继续当前测试运行的方式来实现 Assert 对象的方法,而不是在断言失败时中止测试。

我们将从一些基本部分开始。我们需要一个 Assert static 类的实现。我们还需要实现所有 NUnit 使用的自定义属性类。这些类将被放入一个可以通过编译器指令控制的命名空间中。在 LogRunner 的情况下,这将是 LogRunner.Framework 命名空间。

Assert

Assert 提供 static 方法来执行各种测试。IsTrue 碰巧是最有用的,因为每个问题都可以表述为 True/False。所有其他方法仅为方便和可读性而存在。此实现还包含指示当前 TestFixtureMethod 的属性,以及一个失败断言调用的集合(List)。

失败日志使用了 FailedAssert 类。这只是一个方便的类,用于将关于断言的元信息捆绑到日志集合中。我将留给您在示例代码中研究 FailedAssert 类。

Assert 源代码列表(简要)

using System;
using System.Collections.Generic;
using System.Text;

namespace LogRunner.Framework
{
    /// <summary>
    /// Logging Assert class implementation.  This adds items to a log
    /// for later retrieval so whole smoke test can run.
    /// </summary>
    static class Assert
    {
        #region Properties about assert

        private static string _classUnderTest = null;
        private static string _methodUnderTest = null;
        private static List<FailedAssert> _failedAsserts = null;

        /// <summary>
        /// Name of class (TestFixture) currently being tested.
        /// THIS IS NOT THREAD SAFE
        /// </summary>
        public static string ClassUnderTest
        {
            get { return _classUnderTest; }
            set { _classUnderTest = value; }
        }

        /// <summary>
        /// Name of method (Test) currently being tested.
        /// THIS IS NOT THREAD SAFE
        /// </summary>
        public static string MethodUnderTest
        {
            get { return _methodUnderTest; }
            set { _methodUnderTest = value; }
        }

        /// <summary>
        /// List of Test Failure meta info
        /// </summary>
        public static List<FailedAssert> FailedAsserts
        {
            get
            {
                if (_failedAsserts == null)
                {
                    _failedAsserts = new List<FailedAssert>();
                }
                return _failedAsserts;
            }
            set
            {
                _failedAsserts = value;
            }
        }

        #endregion

        #region Assert methods

        /// <summary>
        /// Tests if a condition is true.
        /// </summary>
        /// <param name="test"></param>
        public static void IsTrue(bool test)
        {
            IsTrue(test, "[No message for error]");
        }

        /// <summary>
        /// Tests if a condition is true.
        /// If false the error is added to the error Log
        /// </summary>
        /// <param name="test"></param>
        /// <param name="message"></param>
        public static void IsTrue(bool test, string message)
        {
            string className = ClassUnderTest;
            if (className == null) className = "[Unknown]";

            if (test == false)
            {
                AddTestFailureMessage(message);
            }
        }

        #endregion

        /// <summary>
        /// Adds a failure message to the list of failures
        /// </summary>
        /// <param name="message"></param>
        private static void AddTestFailureMessage(string message)
        {
            FailedAsserts.Add(new FailedAssert(ClassUnderTest, 
                MethodUnderTest, message));
        }

        /// <summary>
        /// Tests to see if the test run generated any error messages.
        /// </summary>
        /// <returns></returns>
        public static bool RunWasSuccessful()
        {
            return (FailedAsserts.Count == 0);
        }
    }
}

自定义 Attributes

我们还需要复制 NUnit 使用的所有自定义属性。这些实际上只是用于反射的标记。

Attributes 类源代码

using System;

namespace Log.Framework
{
    [AttributeUsage(AttributeTargets.Class)]
    public class TestFixtureAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class TestFixtureSetUpAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class TestFixtureTearDownAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class SetUpAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class TearDownAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class TestAttribute : System.Attribute { }

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class CategoryAttribute : System.Attribute
    {
        public CategoryAttribute() { }
        public CategoryAttribute(string name) { }

        private string _name;

        /// <summary>
        /// Name of this category
        /// </summary>
        public string Name
        {
            get { return _name; }
            set { _name = value; }
        }
    }
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class IgnoreAttribute : System.Attribute
    {
        public IgnoreAttribute() { }
        public IgnoreAttribute(string reason)
        { this.Reason = reason; }

        private string _reason;

        /// <summary>
        /// Reason for ignoring this test
        /// </summary>
        public string Reason
        {
            get { return _reason; }
            set { _reason = value; }
        }
    }
}

现在我们已经复制了必需的 NUnit 部分。下一步是构建测试运行器,并使用适当的反射来执行测试。xUnit 测试夹具的生命周期大致如下

  1. 加载 TestFixture 类实例
  2. 运行夹具设置
  3. 运行测试设置
  4. 执行测试 1
  5. 运行测试拆卸
    ...
  6. 运行测试设置
  7. 执行测试 N
  8. 运行测试拆卸
  9. 运行夹具拆卸

TestClassInfo

为了辅助测试运行器,LogRunner 使用 TestClassInfo 类来保存关于夹具的元信息。它包含 MethodInfo 属性来表示各种设置和拆卸方法。通过使用此类,TestRunner 可以以更通用的方式处理所有测试。此类还封装了发现要测试的方法的反射逻辑。

为了构建元信息,该类调用 LoadClassToTest 并传入要测试的类的实例。此方法使用以下调用获取所有 public 实例方法

Type testDef = objectToTest.GetType();
MethodInfo[] methods = testDef.GetMethods(
    BindingFlags.Public | BindingFlags.Instance);

然后,它循环遍历每个 MethodInfo 对象,检查其自定义属性,并将其添加到相应的属性中,如下所示

foreach (MethodInfo currentMethod in methods)
    {
        object[] attributes = 
            currentMethod.GetCustomAttributes(true);
        foreach (object attr in attributes)
        {
            string typeName = attr.GetType().Name;
            if ("TestAttribute" == typeName)
            {
                _testMethods.Add(currentMethod);
                break;
            }
            else if ("TestFixtureSetUpAttribute" == typeName)
            {
                FixtureSetupMethod = currentMethod;
                break;
            }
            else if ("TestFixtureTearDownAttribute" == typeName)
            {
                FixtureTeardownMethod = currentMethod;
                break;
            }
            else if ("SetUpAttribute" == typeName)
            {
                TestSetupMethod = currentMethod;
                break;
            }
            else if ("TearDownAttribute" == typeName)
            {
                TestTeardownMethod = currentMethod;
                break;
            }
        }
    }

在加载所有反射信息并获取适当的方法钩子之后,它就可以进行测试了。我将留给您探索包含的源代码中的完整类列表。

TestRunner

TestRunner 负责加载任意数量的程序集,发现其中的所有类,以及在这些类上执行测试。将来,这可能会被抽象成一个 abstract 类或接口,但目前只有一个控制台可以调用的运行器。有关所有当前缺点的列表,请参阅下面的发行说明部分。

运行器与托管应用程序之间的通信通过事件处理。定义了一组可预测的事件(TestFixtureStartedTestComplete 等)。TestComplete 在其参数中包含所有失败的 Assert 的日志,因此目前这是我们发现运行是否成功的地方。您可以查看 LogRunner.Program 来了解 TestRunner 引发的事件正在做什么。

TestRunner 具有两个主要的测试执行方法:ExecuteAssemblyTestsExecuteObjectTests。这些方法使用一些反射,加上我们的 TestClassInfo 对象来实际执行测试。ExecuteAssemblyTests 循环遍历程序集中的所有类型。任何被标记为 TestFixture 的类型都将被运行。ExecuteObjectTests 的实际测试执行需要测试夹具类的实例。为了获得此实例,我们使用 System.Activator.CreateInstance() 来创建该类的实例。然后将此实例传递给 ExecuteObjectTests,后者将创建一个 TestClassInfo 对象,其中包含所有用于测试设置、执行和拆卸的方法指针。下面显示了这些方法的样貌。

    /// <summary>
    /// Execute a particular assembly's tests.
    /// </summary>
    /// <param name="assemblyName"></param>

    public void ExecuteAssemblyTests(string assemblyName)
    {
        if (!TestAssemblies.ContainsKey(assemblyName))
            throw new ArgumentException("Assembly not defined for testing");

        Assembly testAsm = this.TestAssemblies[assemblyName];
        if (testAsm == null) return;

        OnAssemblyTestsStarted(new TestExecutionEventArgs(assemblyName));

        Type[] testTypes = testAsm.GetTypes();
        foreach (Type testClassType in testTypes)
        {
            object[] fixtureAttribs = 
                testClassType.GetCustomAttributes(typeof (
                TestFixtureAttribute), false);
            object[] ignoreAttribs = 
                testClassType.GetCustomAttributes(typeof(IgnoreAttribute), 
                false);
            if (fixtureAttribs.Length == 0) continue;
            if (ignoreAttribs.Length > 0) continue; //ignore at fixture level

            object testInstance = Activator.CreateInstance(testClassType);
            ExecuteObjectTests(testInstance);
        }

        OnAssemblyTestsComplete(new TestExecutionEventArgs(assemblyName));
    }

    /// <summary>
    /// Execute tests defined in an object.
    /// Uses reflection to execute tests defined in the passed object
    /// </summary>
    /// <exception cref="System.ArgumentException">
    /// objectToTest must be a TestFixture</exception>
    public void ExecuteObjectTests(object objectToTest)
    {
        string assemblyName = objectToTest.GetType().Assembly.FullName;
        string fixtureName = objectToTest.GetType().FullName;

        TestClassInfo testClass = new TestClassInfo(objectToTest);
        Framework.Assert.ClassUnderTest = fixtureName;

        OnTestFixtureStarted(new TestExecutionEventArgs(assemblyName, 
            fixtureName));

        //Fixture Setup
        InvokeMethod(testClass.FixtureSetupMethod, testClass.ClassToTest);
        
        //Loop and execute all tests
        foreach (MethodInfo test in testClass.TestMethods)
        {
            try
            {
                System.Diagnostics.Debug.WriteLine(String.Format(
                    "  -Testing: {0}", test.Name));
                Assert.MethodUnderTest = test.Name;

                //start run with a fresh log
                Assert.FailedAsserts = new List<FailedAssert>();

                OnTestStarted(new TestExecutionEventArgs(
                    assemblyName, fixtureName, test.Name));
                InvokeMethod(testClass.TestSetupMethod, 
                    testClass.ClassToTest);
                InvokeMethod(test, testClass.ClassToTest);
                InvokeMethod(testClass.TestTeardownMethod, 
                    testClass.ClassToTest);
            }
            catch (Exception ex)
            {
                //add one failed assert for exception
                Assert.FailedAsserts.Add(new FailedAssert(assemblyName, 
                    fixtureName, test.Name, "Exception: " + ex.Message));
                OnTestExecutionException(new TestExecutionExceptionEventArgs(
                    ex, assemblyName, fixtureName, test.Name));
            }
            finally
            {
                OnTestComplete(new TestExecutionEventArgs(assemblyName, 
                    fixtureName, test.Name, Assert.FailedAsserts));
            }
        }
        InvokeMethod(testClass.FixtureTeardownMethod, testClass.ClassToTest);
        OnTestFixtureComplete(new TestExecutionEventArgs(assemblyName, 
            fixtureName));
    }

魔法在哪里?

问得好。如前所述,LogRunner 是 NUnit 语法兼容的,而不是二进制兼容的。这意味着您的 NUnit 测试夹具无法直接运行。它们需要重新编译才能绑定到 LogRunner 中定义的对象集。您应该不需要重写您的测试。但是,您需要对编译标志和预处理器语句进行一些小改动。

首先,为任何您希望在 LogRunner 下运行的项目添加对 LogRunner.exe 的引用。然后,在每个测试夹具类中,将您当前的 using NUnit.Framework; 语句更改为以下内容

#if LOGRUNNER
using LogRunner.Framework;
#else
using NUnit.Framework;
#endif

最后,在 Visual Studio 中将条件编译器指令 LOGRUNNER 添加到您的项目(项目 > [项目名称] 属性 > 生成 > 条件编译符号),或者在命令行编译时设置该值。您的代码现在已准备好供 LogRunner 使用。要切换回 NUnit,只需删除编译器指令即可。

结论

验收测试,特别是 Web 应用程序验收测试,与单元测试的需求不同。虽然单元测试运行器相当成熟,但验收测试运行器还有很长的路要走。除了在反射、WatiN 和条件编译方面做一些有趣的事情之外,这也是一项尝试,旨在让我们更接近验收测试运行器所需的功能。

发行说明

  • 目前,所有命令行开关都被忽略;只识别程序集路径
  • 测试级别的 [Ignore] 属性被忽略
  • 结果仅作为纯文本发送到 stdout;不支持 XML
  • 在下一个版本中,我可能会尝试支持 NUnit XML 输出文件 TestResult.xml
  • 仅支持一小部分 Assert 方法
  • 不支持单个夹具和单个测试执行
  • LogRunner 在 Apache 许可证 2.0 版本下分发

历史

  • 2007 年 11 月 14 日:发布原始版本

许可证

本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。

作者可能使用的许可证列表可以在此处找到。

© . All rights reserved.