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

自动化 UI 测试 - 敏捷团队的另一种方法

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2015年10月1日

CPOL

15分钟阅读

viewsIcon

19737

downloadIcon

448

本文介绍了一种用于自动化用户界面 (UI) 测试的新型替代方法。

引言

本文介绍了一种新的自动化用户界面测试替代方法。传统方法试图从外部测试 UI。本方法从应用程序内部测试 UI。通过从应用程序内部进行测试,您可以直接访问实际的窗体和控件。这些测试可以使用开发团队熟悉的语言编写,例如 .Net/C#/VB.Net。它们使用 NUnit 或任何其他单元测试框架运行。在本文中,我将以 NUnit 为例。这种新的测试编写方法允许使用与编写单元测试非常相似的技术来测试应用程序的 UI。

背景

由于所有示例都使用了 C#,因此具备 WinForm UI 编程背景会很有帮助。本方法可应用于 WPF UI 应用程序,因为技术相同。如果您已经熟悉传统的 UI 自动化测试方法及其缺点,这将非常有帮助。这包括第三方测试工具。了解 Microsoft Automation Framework 也有帮助。

如果您正在寻找按键记录器/播放器解决方案,那么此方法不适合您。

敏捷团队自动化 UI 测试的替代方法

以下是制定此方法时使用的目标

  • 这些测试应使用与应用程序相同的语言进行编码。例如:C#、VB.Net 等。
  • 它必须成本低廉,这意味着不需要额外的工具。应使用您已有的工具。例如:Visual Studio、NUnit 等。
  • 应使用 NUnit 运行这些测试。如果您已经在编写单元测试,那么您已经拥有了此类工具。无需编写自己的测试运行器。如果没有,您可以免费下载 NUnit 运行器。
  • 这些测试是使用编译时绑定到应用程序代码构建的。如果删除了一个类或控件,测试将在编译时失败。这是使测试更具鲁棒性的重要一步。
  • 生产代码应进行最少的修改以支持这些自动化测试。对生产代码进行小的安全更改是可以接受的。
  • 应可以在任何人的机器上以调试或发布模式运行这些测试。
  • 这些测试不应随产品一起发布。

架构

传统方法试图从应用程序外部进行测试。本方法涉及打开您的应用程序以便从内部进行测试。这是一个思维转变,但它也使这种方法如此强大。它也使其能够解决传统方法的缺点。

以下是如何打开您的应用程序以进行测试

  • 被测应用程序 (AUT) 动态加载新定义的应用程序测试 DLL (ATD)。
  • ATD 在项目引用中引用 AUT。如果这听起来像循环依赖,那是因为它就是。这可以通过让 AUT 动态加载 ATD 来解决。ATD 仅在存在时加载,这意味着您不必将 ATD 与生产代码一起发布。别担心,AUT 只是加载和初始化 ATD,而不用于其他任何事情。相反,ATD 在运行测试时使用 AUT 中的窗体和类。
  • AUT 在加载 ATD 后调用并初始化它。
  • 在初始化代码中,ATD 打开一个 WCF(或 .Net remoting,我将使用 WCF)通道,该通道可以由 NUnit 运行的单元测试调用。
  • 此通道通过接口公开测试方法。由于 ATD 是由 AUT 动态加载的,因此它运行在 AUT 的进程空间内。因此,它可以完全访问主窗体和所有子窗体。
  • 测试是在 ATD 中编写的,它们直接控制 UI。这些测试可以单击按钮、菜单项、在表单上输入数据、关闭表单以及验证数据。所有这些都可以使用实际的类和控件完成。您不再需要通过硬编码的名称查找控件。
  • 创建一个单元测试 DLL (UTD),并通过 WCF 通道连接到 ATD。
  • 在 UTD 中编写的精简包装器作为 [Test] 方法,调用 ATD 中的实际测试。这允许我们使用 NUnit 作为我们的测试运行器。
    • 注意:NUnit 无法直接加载 ATD,因为 ATD 必须由 AUT 加载才能在应用程序的进程空间中运行,如前所述。
  • 就是这样!

下图显示了高层架构。

被测应用程序 (AUT)

在本文中,我创建了一个简单的 WinForms 应用程序(此方法也可用于 WPF 应用程序)来管理联系人。它有三个按钮:添加、修改和删除。它还有一个菜单选项“文件|保存”。示例应用程序包含了创建应用程序和运行所有选项测试所需的全部代码。

动态加载测试 DLL

应用程序的加载事件处理程序调用 DynamicallyLoadTestingDllIfPresent 方法。此方法检查应用程序测试 DLL 是否存在。如果存在,它会加载并初始化它。

        private static void DynamicallyLoadTestingDllIfPresent()
        {
        ...
                try
                {
                    System.Diagnostics.Trace.WriteLine( @"Test dll found. Attempting load.");

                    // Dynamcially load the DLL
                    Assembly loadedDLL = Assembly.LoadFrom(testingDll);
                    
                    // Get the type of object to call to initialize the DLL
                    Type t = loadedDLL.GetType("ContactManager.Testing.AppTestingDLLInitialization");
                    
                    // Create an instance of the object which should initialize the DLL and open 
                    // up a WCF channel
                    Activator.CreateInstance(t);

                    System.Diagnostics.Trace.WriteLine(@"Successfully loaded the testing dll.");
                }
        ...
        }

打开应用程序以进行测试

这是此方法的独特之处。由于您希望直接控制您的窗体和控件,因此您需要能够在测试代码中访问它们。您可以使所有需要的用于测试的控件和方法公开。但这会污染公共命名空间,并且仅为了测试而将成员设为 public 似乎不合适。但是,您确实需要访问这些控件和方法。另一种可能的方法是使用反射。您可以使用反射访问私有成员,网上有很多示例;这里是其中一个:例子。然而,使用反射并不简单,并且它存在一些与传统方法相同的问题,例如必须硬编码控件名称,而这应该避免。

此示例在要访问的每个窗体中创建了一个嵌套类作为成员。由于嵌套类以外部类作为参数,因此它可以完全访问外部类的所有私有方法和属性。请参阅“为窗体添加测试启用代码”部分。这解决了几个问题。

  • 窗体的所有方法和属性都对嵌套类中编写的测试方法可访问。这包括公共、受保护和私有成员。
  • 公共命名空间不会因编写用于测试的成员而受到污染。
  • 在访问这些成员时,您必须通过嵌套类(名为 Testing)进行访问。这提醒您正在访问的方法仅用于自动化测试。
  • 它提醒在嵌套类中编写代码的人,任何 UI 访问都需要通过调用 BeginInvoke 或 Invoke 进行保护。请参阅“BeginInvoke 或 Invoke:这是个问题”部分。

有什么缺点

  • 向公共命名空间添加了一个附加属性“Testing”。
  • 生产代码可以访问这些方法,尽管这不是意图。您可以通过条件编译掉这些代码来防止这种情况发生,但这本身也存在问题。我宁愿将这些方法保留在生产代码中,并通过其他方式验证它们不在生产中使用。此处不研究其他方法。
  • 您正在修改生产代码,即使它是安全的(因为它位于嵌套类中,该类直到第一次被调用时才被创建),并且占用的资源很少(您不需要太多这种代码,因为大部分测试代码都放在 ATD 中)。

BeginInvoke 或 Invoke:这是个问题

为了编写这些测试,您需要了解 Windows 编程的以下一些事实:

  • Windows 应用程序只有一个 UI 线程。
  • 任何 UI 修改都必须在 UI 线程上进行。这包括:
    • 设置编辑框的文本。
    • 在列表框中选择一项。
    • 按下按钮
    • 等等。
  • 如果您在后台线程上运行,并且想要修改 UI,您需要切换到 UI 线程来完成。
  • 有两种方法可以从后台线程切换到 UI 线程:
    • BeginInvoke - 这是一个异步调用,因此调用线程不会等待调用完成。
    • Invoke - 这是一个同步调用,因此调用线程被阻塞并等待调用完成。
    • 注意,示例应用程序有两个扩展方法:UseBeginInvokeIfRequired 和 UseInvokeIfRequired,它们可以更容易地切换到 UI 线程。有关详细信息,请参阅示例代码。

了解何时必须使用 BeginInvoke 或 Invoke 以及使用哪一个可能是编写这些测试中最棘手的部分。以下一些经验法则应该对您很有帮助:

  • 如果您不修改 UI,请使用后台线程。
  • 如果您正在执行可能产生副作用的操作,例如打开或关闭一个窗体,请使用 BeginInvoke。这可以由工具栏按钮、菜单项或窗体上的按钮触发。这是必要的,因为执行这些操作时会生成许多额外的消息。如果您处于阻塞调用中,您可能会陷入死锁状态。
  • 如果您需要修改或访问 UI 控件的值,例如:设置文本框的文本、选中单选按钮、在列表视图中选择一项,请使用 Invoke。
  • 由于 Invoke 调用是同步的,因此它使编写测试更容易,因为您不必在调用下一行测试代码之前等待调用完成。

为窗体添加测试启用代码

此代码显示了 AddModifyContactForm 如何被扩展。为了打开窗体进行测试,在 AddModifyContactForm.Testing.cs 文件中使用了部分类关键字添加了以下代码。使用单独的文件是为了在生产代码和测试代码之间保持清晰的分离。请注意,测试方法已添加到名为 Testing 的嵌套类中。注意,对于 ContactManagerForm 也使用了相同的技术,有关详细信息,请参阅示例代码。

namespace ContactManager
{
    public partial class AddModifyContactForm
    {
        private NestedTestCode _testing;
        /// <summary>
        /// Instance of nested class to add all test specific code to.
        /// </summary>
        public NestedTestCode Testing
        {
            get { return _testing ?? (_testing = new NestedTestCode(this)); }
        }

        public class NestedTestCode
        {
            private readonly AddModifyContactForm _outerClass;

            /// <summary>
            /// In order to access the protected and private methods of the outer class we need an instance of the
            /// object that created us. From this instance we can access anything.
            /// </summary>
            /// <param name="outerClass"></param>
            public NestedTestCode(AddModifyContactForm outerClass)
            {
                if (outerClass == nullthrow new ArgumentNullException();
                _outerClass = outerClass;
            }

            // Methods used by the test code ...

            public void SetData(ContactRow contactRow)
            {
                // Set the values on the form with the values in the passed in contact row.
                // This action can be synchronous since it has no side effects so use Invoke 
                // to get to the UI thread if needed.
                _outerClass.UseInvokeIfRequired(() =>
                {
                    _outerClass.textBoxName.Text = contactRow.Name;
                    if(contactRow.Gender == ContactRow.GenderType.Male)
                        _outerClass.rbMale.Checked = true;
                      else
                          _outerClass.rbFemale.Checked = true;
                });
            }

            public void PushCancelButton()
            {
                // Press the Cancel button to abort the operation.
                // This action needs to be ansynchronous to allow the form to close so use BeginInvoke 
                // to get to the UI thread if needed.
                _outerClass.UseBeginInvokeIfRequired(_outerClass.buttonCancel.PerformClick);
            }

            public void PushSaveButton()
            {
                // Press the Save button to abort the operation.
                // This action needs to be ansynchronous to allow the form to close so use BeginInvoke 
                // to get to the UI thread if needed.
                _outerClass.UseBeginInvokeIfRequired(_outerClass.buttonSave.PerformClick);
            }
        }
    }
}

应用程序测试 DLL (ATD)

应用程序测试 DLL (ATD) 是大部分测试代码所在的位置。ATD 包含所有过程化的、一步一步的测试代码。有关所有详细信息,请参阅示例应用程序。

初始化 - 动态加载和初始化 ATD

AUT 启动时必须调用 AUT 中的 DynamicallyLoadTestingDllIfPresent 方法。此方法查找 ATD。如果找到 DLL,它会动态加载它。然后,它动态创建 ATD 中 AppTestingDLLInitialization 类的实例。AppTestingDLLInitialization 类有一个静态构造函数,在首次创建此类实例时(请参阅动态加载 ATD)会被调用以对其进行初始化。此类又创建一个 WCFConnection 类的单个实例,该实例打开用于测试的 WCF 接口。

测试方法接口

您想要从 NUnit 调用的每个测试都需要添加到测试方法接口中。此接口目前有四个方法,对应于示例中的四个测试。例如:

namespace ContactManager.Testing
{
    [ServiceContract]
    public interface IWCFConnection
    {
        [OperationContract]  void TEST_Create_Contact();
        …
    }
}

过程化测试代码

ContactManagerTests.cs 类包含所有过程化测试。这些测试包含逐步测试 UI 的代码。这是大部分测试代码的编写位置。以下是“创建联系人”测试的代码。这是测试外观的一个很好的例子。FindFormOfType 和 WaitUntilFormCloses 等辅助方法在 TestsBase 基类中实现。有关详细信息,请参阅示例代码。

        internal void TEST_Create_Contact()
        {
            // ARRANGE...
            Setup("TEST_Create_Contact");

            System.Diagnostics.Trace.WriteLine("Create 5 contacts in a row");
            for (int index = 0; index < 5; index++)
            {
                // ACT...
                System.Diagnostics.Trace.WriteLine("Find the main application form");
                var app = FindFormOfType<ContactManagerForm>();
                
                System.Diagnostics.Trace.WriteLine("Press the add button");
                app.Testing.AddButton_PerformClick();

                System.Diagnostics.Trace.WriteLine("Wait until the add modify form is displayed");
                var itemForm = FindFormOfType<AddModifyContactForm>();

                System.Diagnostics.Trace.WriteLine("Create the test data to use to create the contact");
                string name = string.Format(@"Item {0} Added by Test Code", index + 1);
                ContactRow contactRow = new ContactRow {Name = name, Gender = ContactRow.GenderType.Male};
                
                System.Diagnostics.Trace.WriteLine("Set the data on the form");
                itemForm.Testing.SetData(contactRow);
                
                System.Diagnostics.Trace.WriteLine("Press the save button and wait for the form to close");
                WaitUntilFormCloses(itemForm, itemForm.Testing.PushSaveButton);

                // ASSERT...
                System.Diagnostics.Trace.WriteLine("Verfiy that the contact was added correctly");
                ContactRow itemInDS = DataStore.Instance.GetContactById(itemForm.Contact.ID);
                Assert.IsNotNull(itemInDS);
                Assert.IsTrue(itemInDS.Name == itemForm.Contact.Name);
                Assert.IsTrue(itemInDS.Gender == itemForm.Contact.Gender);
            }
        }

使用 NUnit 运行测试

单元测试 DLL (UTD),UnitTests.Dll,是 NUnit 加载的 DLL。这里编写的测试非常小。它们所做的就是调用在测试接口上实现的并通过 WCF 通道访问的测试。每个测试类(在此示例中只有一个)都需要打开一个通道来调用测试,并在完成后关闭通道。以下是测试外观的示例:

        [TestCase]
        public void CreateContact()
        {
            RunTest(_proxy.TEST_Create_Contact);
        }

处理错误

当 ATD 中的测试失败时,会抛出异常,并最终被 UTD 中的 RunTest 方法捕获。为了使此正常工作,需要进行一些转换,请参阅示例代码以了解详细信息。这会导致测试在 NUnit 中被标记为失败。这可能不足以应对实际应用程序。如果测试失败,您无法保证 AUT 的状态。一个更鲁棒的(但超出本示例范围的)解决方案是在测试失败时杀死 AUT 并重新启动它。要做到这一点,您需要知道在哪里运行 AUT。在实际应用中,我们假设您知道在哪里加载运行您的应用程序。

关于日志记录

为了在本示例中保持简单,我使用了 System.Diagnostics.Trace.WriteLine()。在实际应用程序中,这些语句很可能就是 Log.Write 语句。在测试的每个步骤添加日志记录将使调试测试和代码更容易。

运行测试

现在是激动人心的部分,看看测试是如何运行的。您需要这样做:

  • 下载演示或下载源代码并在您的机器上进行构建。
  • 以调试或发布模式运行示例应用程序。
  • 使用 NUnit 运行器打开 UnitTests.Dll。如果您没有运行器,您可以下载 NUnit
  • 此时,您应该已经打开了应用程序,并且 UnitTests.dll 已加载到运行器中,如下所示:

  • 在 NUnit 中按运行按钮。
  • 所有测试应该运行得非常快,您现在应该看到这个:

此方法的优点和缺点

优点

  • 成本低 - 此方法可以使用您已有的工具,如 Visual Studio 和 NUnit 等开源工具。传统方法需要您可能没有的昂贵工具。
  • 易于使用 - 对于已经了解 .Net 的开发人员来说,编写这些测试比学习新工具和使用 Delphi 脚本等不同语言编码要容易得多。也就是说,您需要一些熟悉高级语言并且最好熟悉应用程序代码的人来编写这些测试。
  • 职业发展 - 当您以这种方式编写测试时,您是在编写代码。如果您和/或您的团队已经接受编写单元和集成测试,那么这实际上没有区别。这使得开发人员更容易愿意参与编写这些测试。对于有兴趣并愿意从事 .Net 工作的技术 QA 自动化工程师来说,这些测试也打开了更多机会。
  • 稳定性 - 由于多种原因,这些测试比传统测试更稳定:
    • 它们可以由编写代码的人在代码编写时(或稍后,但同时进行更有效)编写。
    • 简单的更改,例如移动控件、删除控件、更改控件上的文本或添加布局面板,可能会在运行时破坏传统测试。这些更改不会破坏以这种新方式编写的测试,或者即使它们被破坏(例如,从窗体中删除了一个控件),编译器也会在更改时告知您测试已损坏。
    • 还有更多...
  • 如果在夜间运行中测试失败,开发人员或 Scrum 团队成员可以在他们的桌面上尝试完全相同的测试。如果产品中的某些内容发生了变化导致测试失败,开发人员可以修复代码和/或测试,并在提交更改之前验证测试是否已成功运行。如果测试仅在夜间运行中失败,开发人员就知道这是一个环境问题,可以在夜间运行机器上进行故障排除。同样,可以使用开发人员已知的工具重新运行测试。

缺点

  • 易于使用 - 您的团队必须熟悉高级语言。深入了解被测代码很有帮助,因为您将直接访问类和控件。
  • 职业发展 - 如果您和/或您的团队认为 UI 测试是别人的工作,那么这种方法可能不适合您。
  • 处理多线程问题 - 知道何时以及如何从后台线程切换到 UI 线程,即使对于经验丰富的开发人员来说也可能很棘手。为了使用这种方法,您必须处理 UI 多线程问题(请参阅上面“BeginInvoke 或 Invoke:这是个问题”)。
  • 谁可以写 - 使用脚本语言或录制回放技术的自动化工程师不太可能编写这些测试。如果您的自动化团队具备 .Net 技能或有兴趣学习这些技术,这可能是一个优点而不是缺点。

摘要

敏捷团队需要一种有效的 UI 测试方法。在 Mike Cohn 的帖子“测试自动化金字塔中被遗忘的层”中,他说您希望 0-10% 的测试是 UI 测试。即使在这个低百分比下,您仍然需要一种有效的编写方法。传统工具无法利用敏捷团队成员的技能,而且购买成本高昂且维护困难。这种方法利用了团队成员的技能,并使团队能够在其编写功能时就拥有其质量。

历史

初稿。

© . All rights reserved.