UITestBench,一个轻量级的 UI 测试库
本文介绍了如何构建一个轻量级的测试平台,用于测试完全用 C#/.NET 编写的用户界面,可以使用 NUnit 或任何其他单元测试框架。
引言
本文介绍了 UITestBench,一个小型但高效的库,用于实现用户界面测试,这些测试可以使用 NUnit 或任何其他单元测试框架运行。本项目随附的源代码是一个 Visual Studio 2005 解决方案,分为三个部分/项目
- 一个小型示例应用程序
- UITestBench 类
- 使用 UITestBench 为演示应用程序编写的两个 NUnit 测试用例
要求
UITestBench 在开发过程中考虑了以下要求/假设
- 待测试应用程序 (AUT) 是用纯 .NET 编写的。
- AUT 不需要针对 UI 测试进行开发。
- AUT 无需修改即可实现/运行测试。
- AUT 不得依赖任何测试类(因此,也可以测试发布版本)。
- UITestBench 库应独立于待测试应用程序和单元测试框架。
以下 UML 图显示了项目不同部分之间的依赖关系
可以看出,这些要求都得到了满足,因为图显示 AUT 不依赖于任何测试类或其他包,并且 UITestBench
独立于 NUnit 框架和 AUT。
Tasks(任务)
为了执行 UI 测试,需要实现以下任务
- 从测试用例中启动 AUT。
- 扫描应用程序可用的 UI 元素(按钮、菜单项、列表等)。
- 对这些元素执行操作。
- 确保在测试用例结束时关闭应用程序。
第一个和最后一个步骤可以为每个测试用例执行一次,或者为一组测试用例执行一次。
以下 UML 序列图显示了一个最小的用例可能是什么样子
启动待测试应用程序
使用 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
方法现在执行以下步骤
- 创建一个字典,该字典将通过唯一 ID 存储扫描的元素。
- 调用
ScanUIElementsOfControl
以递归扫描窗体的元素(窗体也属于Control
类型)。 - 并用新扫描的元素替换窗体旧的元素(如果以前扫描过)。
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
对象。以下类图显示了此结构
使用 .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 - 初始版本。