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

UITestBench,一个轻量级的 UI 测试库

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2008年4月3日

CPOL

9分钟阅读

viewsIcon

54836

downloadIcon

245

本文介绍了如何构建一个轻量级的测试平台,用于测试完全用 C#/.NET 编写的用户界面,可以使用 NUnit 或任何其他单元测试框架。

引言

本文介绍了 UITestBench,一个小型但高效的库,用于实现用户界面测试,这些测试可以使用 NUnit 或任何其他单元测试框架运行。本项目随附的源代码是一个 Visual Studio 2005 解决方案,分为三个部分/项目

  • 一个小型示例应用程序
  • UITestBench 类
  • 使用 UITestBench 为演示应用程序编写的两个 NUnit 测试用例

要求

UITestBench 在开发过程中考虑了以下要求/假设

  • 待测试应用程序 (AUT) 是用纯 .NET 编写的。
  • AUT 不需要针对 UI 测试进行开发。
  • AUT 无需修改即可实现/运行测试。
  • AUT 不得依赖任何测试类(因此,也可以测试发布版本)。
  • UITestBench 库应独立于待测试应用程序和单元测试框架。

以下 UML 图显示了项目不同部分之间的依赖关系

dependencyDiagram.png

可以看出,这些要求都得到了满足,因为图显示 AUT 不依赖于任何测试类或其他包,并且 UITestBench 独立于 NUnit 框架和 AUT。

Tasks(任务)

为了执行 UI 测试,需要实现以下任务

  • 从测试用例中启动 AUT。
  • 扫描应用程序可用的 UI 元素(按钮、菜单项、列表等)。
  • 对这些元素执行操作。
  • 确保在测试用例结束时关闭应用程序。

第一个和最后一个步骤可以为每个测试用例执行一次,或者为一组测试用例执行一次。

以下 UML 序列图显示了一个最小的用例可能是什么样子

SequenceRoleDiagram1.png

启动待测试应用程序

使用 Windows Forms 的应用程序必须在 STA 线程公寓中运行(更多详细信息,请参阅 MSDN)。通常,这是通过将 STAThreadAttribute 应用于应用程序的 Main 方法来完成的。

[STAThread]
static void Main() { 
    //Start the WinForms application...
    ...
}

但是,当从单元测试中调用应用程序时,不能确定实际的线程公寓状态。但这不成问题。由于我们需要创建一个新线程来运行应用程序(原始测试线程用于在新线程上运行针对应用程序的命令(测试用例)),因此我们只需将此线程设置为 STA 线程。这可以通过以下方式完成

public void StartApplication(string assemblyName, object args)
{
    Assembly assembly = Assembly.Load(assemblyName);

    if (assembly != null)
    {
        //Invoke the application under test in a new STA type thread
        uiThread = new Thread(new ParameterizedThreadStart(this.Execute));
        uiThread.TrySetApartmentState(ApartmentState.STA);
        uiThread.Start(new ApplicationStartInfo(assembly, args));
    }
    else
    {
        throw new Exception("Assembly '" + assemblyName +"' not found!");
    }
}

在线程的构造函数中,将一个新的委托作为线程的入口点传递。通过 Assembly-Type 对象的 EntryPoint 属性调用作为委托方法传递的 Execute 方法,该对象作为参数传递。实际上,作为参数传递的对象是一个帮助类 ApplicationStartInfo,它包含要用作的程序集对象以及传递给应用程序主方法的参数对象(例如,作为 string[])。

private void Execute(object param)
{
    Console.WriteLine("UIExecute ThreadId: " + 
                      Thread.CurrentThread.ManagedThreadId);

    Application.ThreadException += new 
      ThreadExceptionEventHandler(Application_ThreadException);

    ApplicationStartInfo startInfo = (ApplicationStartInfo)param;

    Assembly ass = startInfo.AssemblyToStart;
    if (ass.EntryPoint.GetParameters().Length == 1)
    {
        ass.EntryPoint.Invoke(null, new object[] { startInfo.Arguments });
    }
    else
    {
        ass.EntryPoint.Invoke(null, new object[] { });
    }
}

因此,在测试夹具的 SetUp 方法中启动应用程序就像这样简单

[SetUp]
public void SetUp()
{
    myTestBench = new UITestBench();
    myTestBench.StartApplication("FlexRayReplayScriptGenerator", 
                                 new string[] { });

    //Some time to start the demo app
    Thread.Sleep(2000);
    ///Here the framework could be extended to wait
    ///for a certain dialog title to appear instead of a fixed delay...
}

扫描可用的 UI 元素

一旦应用程序启动并且单元测试可以调用第一个 UI 操作(请参阅下一节),就必须扫描应用程序的打开窗体以获取可用的 UI 控件。我们如何访问应用程序的打开窗体?这比预期的要容易得多。可以直接使用 Application 类的静态 OpenForms 属性。

foreach (Form openForm in Application.OpenForms)
{
    //Let the name be the form id
    string formId = formToScan.GetType().Name;

    ScanUIElementsOfForm(openForm, formId);
}

ScanUIElementsOfForm 方法现在执行以下步骤

  1. 创建一个字典,该字典将通过唯一 ID 存储扫描的元素。
  2. 调用 ScanUIElementsOfControl 以递归扫描窗体的元素(窗体也属于 Control 类型)。
  3. 并用新扫描的元素替换窗体旧的元素(如果以前扫描过)。
private void ScanUIElementsOfForm(Form formToScan, string formId)
{
    IDictionary<string,> newlyScannedElements = new Dictionary<string,>();

    //Get all supported UI elements of the form
    ScanUIElementsOfControl(formToScan, newlyScannedElements, 
          formToScan.GetType().Name, formToScan.GetType().Name);

    //Set the owner of the elements to the scanned form
    foreach (string key in newlyScannedElements.Keys)
    {
        newlyScannedElements[key].OwningForm = formToScan;
        Console.WriteLine(key);
    }

    //Remove existing form info 
    if (uiForms.ContainsKey(formId))
    {
        uiForms.Remove(formId);
    }

    //And replace with new form info about the newly scanned elements
    uiForms.Add(formId, new UIFormInfo(formToScan.Text, newlyScannedElements));
}

对于每个扫描的元素,都会创建一个 UIElementInfo 对象,该对象包含到扫描元素和所有者窗体的 WeakReference。后者是能够对元素执行操作所必需的。对于每个扫描的窗体,都会创建一个 UIFormInfo 对象,该对象包含窗体的 UIElementInfo 对象。以下类图显示了此结构

UIElements.PNG

使用 .NET 类 WeakReference 来存储到扫描窗体和元素的引用,否则,即使在对话框关闭后,这些对象也不会被 .NET 垃圾回收器收集。正常的强引用(例如,将对象分配给变量)会阻止引用的对象被收集。但是,当没有其他对该对象的强引用时,即使它仍有弱引用,目标对象也符合垃圾回收条件。因此,通过使用弱引用,UI 测试不会干扰应用程序垃圾回收的自然行为。

UIElementInfo 类只允许通过相应的属性访问 WeakReferences,这些属性包括对已引用对象是否仍然存活的检查。如果不是这种情况,则会抛出异常,测试用例将失败。

上面方法中使用的 ScanUIElementsOfControl 方法将传递的控件的所有子控件递归添加到字典中。因此(当然,也为了能够构建测试用例),每个元素都必须有一个唯一的 ID。此唯一 ID(窗体内唯一)的构造遵循以下规则

  • 如果控件的 AccessibleName 属性是一个长度大于 0 的字符串,则将其用作键。
  • 否则,如果 Text 属性不是 null 且长度大于 0,则使用该属性。

如果此键已被另一个控件使用(例如,如果它具有相同的 AccessibleName),则将控件的完整路径(通过所有父控件)用作键。这始终是唯一的,因为每个控件都被分配了一个索引。为了更好地理解,控件的路径名由该索引和控件的类型名称构成。

private void ScanUIElementsOfControl(Control control, 
        IDictionary<string,> uiElements, string path, string parent)
{
    int itemIdx = 0;
    foreach (Control childControl in control.Controls)
    {
        string myPath = path + "/" + childControl.GetType().Name + 
                        "[" + itemIdx + "]";
        string key = myPath;
        //Use the parent name + accessible name as key if possible
        if (childControl.AccessibleName != null && 
            childControl.AccessibleName.Length > 0)
        {
            key = parent + "/" + childControl.AccessibleName;
        }
        else if (childControl.Text != null && childControl.Text.Length > 0)
        {
            //Else use the parent name and the controls text as key
            key = parent + "/" + childControl.Text;
        }

        //Use the shorter key if not yet used
        if (!uiElements.ContainsKey(key))
        {
            uiElements.Add(key, new UIElementInfo(childControl, key));
        }
        else
        {
            //Else use the unique path to the element
            uiElements.Add(myPath, new UIElementInfo(childControl, myPath));
        }

        ScanUIElementsOfControl(childControl, uiElements, myPath, 
                                childControl.GetType().Name);
        itemIdx++;
    }

    //It might be a ToolStrip
    ToolStrip strip = control as ToolStrip;
    if (strip != null)
    {
        ScanToolStripItems(strip.Items, uiElements, path, strip.Text);
    }
}

最后,检查给定的控件是否为 ToolStrip,因为 foreach 循环不适用于 ToolStrip,因为对于它们,Controls 属性始终返回一个空集合。由于 ScanToolStripItems 方法的工作方式类似,因此在此不再详细阐述。

为了能够轻松构建测试用例,扫描元素的键被写入控制台。因此,当使用 NUnit GUI 时,我们可以在“Console.Out”选项卡中轻松看到键。对于演示应用程序,主窗体的元素使用以下键标识

Form1/SplitContainer[0]
Form1/SplitContainer[0]/SplitterPanel[0]
Form1/SplitContainer[0]/SplitterPanel[0]/ListBox[0]
Form1/SplitContainer[0]/SplitterPanel[1]
Form1/SplitContainer[0]/SplitterPanel[1]/ListBox[0]
Form1/toolStrip1
toolStrip1/Merge
Form1/menuStrip1
menuStrip1/File
File/Open text file 1...
File/Open text file 2...
File/
File/Exit

由于每次打开新窗体都需要搜索 UI 元素,因此可以在测试用例中手动调用该算法,或者在访问 UI 元素失败时调用该算法,即找不到 UI 元素 ID。如果在重新扫描后仍找不到该元素,则会抛出异常,测试用例将失败。

执行 UI 操作

对于不同类型的 UI 元素,可以执行不同的操作。UITestBench 提供了多种便捷方法来访问最常见的操作,例如单击按钮或在文本框中设置文本。当然,如果应用程序中需要频繁执行某些操作,可以轻松添加更多便捷方法。提供了以下方法

  • public void PerformClick(String formKey, String itemKey)
  • public void SetText(String formKey, String itemKey, string text)
  • public void SetSelectedIndex(String formKey, String itemKey, int index)

要执行操作,必须提供窗体的 ID 和窗体内的 UI 元素的唯一 ID。根据所需操作,必须传递其他参数(例如,要设置的文本)。在内部,这些方法使用委托对相关 UI 元素执行操作,因为在某些情况下(取决于应用程序的启动方式),当从与创建控件不同线程的线程调用控件时,会抛出“Illegal Cross Thread Operation”异常。只需在 Google 上搜索“illegal cross thread C#”即可获取有关此问题的更多信息。

处理外部对话框

在实现 UI 单元测试时,最后一个问题是出现外部对话框,即非用户实现或甚至非 .NET 基于的对话框。此类对话框的一个典型示例是“打开文件”对话框,演示应用程序也使用了该对话框。处理此类对话框的一种简单方法是,只要它们不太复杂,就可以使用 SendKeys 类通过发送击键来处理。因此,要打开文件对话框,选择一个文件,然后打开它。对于使用标准 .NET 文件对话框的演示应用程序,只需以下步骤

myTestBench.PerformClick("Form1", "File/Open text file 1...");
SendKeys.SendWait("file1.txt");
SendKeys.SendWait("{ENTER}");

完成测试

测试用例完成后,NUnit 会调用 TearDown 方法。现在使用此方法调用测试平台的 TerminateApplication 方法,以确保启动的应用程序被关闭。

[TearDown]
public void TearDown()
{
    myTestBench.TerminateApplication();
}

public void TerminateApplication()
{
    Console.Write("Forcing application to shut down " + 
                  "if it has not terminated already....");
    if (uiThread != null && uiThread.IsAlive)
    {
        Console.WriteLine("Done!");
        uiThread.Abort();
    }
    else
    {
        Console.WriteLine("not required!");
    }
}

限制

目前,无法拥有两个相同类型的打开窗体,因为类型名称用作唯一键。通过简单地为重复类型附加索引,可以对此进行更改。但是,此演示项目或简单应用程序不需要这样做。

使用代码

下载包含一个 Visual Studio 2005 解决方案,该解决方案分为三个不同的项目:演示应用程序、测试平台框架和测试用例实现。使用 Visual Studio 打开解决方案后,演示应用程序被设置为启动项目,即只需按 F5 即可启动它。要执行测试用例,您需要使用 NUnit GUI 打开“UITestDemoApp_UITest.dll”,该文件包含在“UITestDemoApp_UITest\bin\Debug”文件夹中。

结论

本项目展示了如何基于标准的 .NET 框架构建一个简单但非常实用的 UI 测试框架。这里的主要关注点是使用 Application.OpenForms 属性访问打开的窗体,在单独的 STA 类型线程中执行待测试应用程序,使用弱引用收集 UI 元素,以及使用委托调用 UI 元素以防止无效的跨线程操作。基于这个基本框架,可以轻松实现额外的 UI 测试功能,以根据特定需求定制此项目。这非常适合测试中小型 .NET 应用程序,而无需购买昂贵和/或复杂的外部解决方案。

历史

  • [2008.04.03] - 1.0 - 初始版本。
© . All rights reserved.