修复 CodedUI 测试的技巧






4.68/5 (9投票s)
本文介绍了一些修复 CoedUI 自动化测试的技巧,包括一些通用的方法,而不是许多更具体的功能定义,来处理诸如对话框和表格等有问题组件,以及用于处理生成测试数据的复杂查询的 SQL 模板和 JavaScript。
引言
在通过修复一些 Microsoft CodedUI 测试工作了 4 个月之后,现在是时候在转向移动测试之前总结一下从这次经验中获得的技术和技巧了。
由于我公司的属性限制,本文将侧重于概念实现而不是可编译的代码。希望这能为该领域的自动化测试人员提供一些线索或启发,以应对他们面临的问题。
背景
Microsoft Coded UI 测试 (CUIT) 框架对 Windows 应用程序、Web 应用程序、WPF 应用程序、SharePoint、Office 客户端应用程序 Dynamics CRM Web 客户端应用程序提供了良好的支持。对于 Web 应用程序,开发 CodedUI 测试的传统方法在很大程度上依赖于 Microsoft 工具生成的 UIMap。然而,这意味着如果 Web 应用程序发生变化,页面对象和方法也必须随之更新,这实际上会导致长期问题。在本文中,我将介绍一些虽然不是 Microsoft 推荐但能有效且高效地修复一些常见问题的技术。
主题
谨慎使用 UIMap.designer.cs
我遇到的第一个挑战是对话框处理,它实际上暴露了 Microsoft CodedUI 代码模板对于任何 *.designer.cs 的一个基本问题:就像其他典型的 CUIT 测试一样,DialogWindow 的代码是由 Coded UI 生成的,如下所示:
public DialogWindow DialogWindow
{
get
{
if ((this.mDialogWindow == null))
{
this.mDialogWindow = new DialogWindow();
}
return this.mDialogWindow;
}
}
这个自动生成的 getter 用于定位测试过程中出现的某些对话框窗口,顾名思义,并且在多个测试中使用相同的实例 (this.UIMap.somePage.DialogWindow) 来引用**多个对话框窗口**。有时,在使用它来引用和关闭一些弹出对话框时,测试会失败,并出现异常,称引用的 DialogWindow 实例已过时或类似的信息。之所以发生此类错误,是因为 mDialogWindow 实例最初是 null,因此当第一次调用 DialogWindow 时,mDialogWindow 将被实例化以指向一个活动的对话框。然而,在使用它关闭对话框后,mDialogWindow 可能仍然持有已被关闭和释放的对话框实例。因此,当 CodedUI 框架使用 DialogWindow 来匹配另一个模态对话框时,它不会搜索,因为 mDialogWindow 确实持有不再存在的东西,从而导致测试失败。
显然,上面的代码应该生成如下:
public DialogWindow DialogWindow
{
get
{
if ((this.mDialogWindow == null || !this.mDialogWindow.Exists))
{
this.mDialogWindow = new DialogWindow();
}
return this.mDialogWindow;
}
}
然而,UIMap.designer.cs 中的属性是自动生成的,因此任何修改都会被覆盖。
与其将此类属性从 UIMap.designer.cs 移到 UIMap.cs,不如在 UIMap.cs 或 Dialog.cs 中声明一个静态属性,如下所示:
private static DialogWindow currentDialog = null;
public static DialogWindow CurrentDialog
{
get
{
if (currentDialog == null || !currentDialog.DialogWindow.Exists)
{
currentDialog = new DialogWindow();
}
return currentDialog;
}
}
用 "UIMap.CurrentDialog" 替换 "UIMap.DialogWindow" 等变量,可以使对话框窗口处理更加可靠。因为 CUIT 使用相同类型的代码来引用和使用控件,这是一个非常根本性的错误:您必须重置 mDialogWindow 等变量以保证属性引用的元素仍然存在;更糟糕的是,将 "AlwaysSearch" 添加到 "Search Configuration" 中并不像预期的那样工作,这就是为什么我更喜欢在使用 JavaScript 之前找到元素。
String 的扩展方法
在许多情况下,测试人员通过将显示结果与某些预期字符串进行比较来验证 Web 应用程序的功能是否正常,但精确地定义这些字符串与产品相同并不是一个好主意。以我修复的项目为例,有数百个方法定义如下:
public void AssertSomeMessage(string dialogMessage, string accountNumber, string billerName, decimal amount, DateTime startDate)
{
var expected = string.Format(
"You are about to do something from your xxxx account {0} to {1} for the amount of {2} scheduled for the {3}.{4}Is this Correct?",
accountNumber, billerName, Format.AsCurrency(amount), Format.AsShortDate(startDate), Environment.NewLine);
StringAssert.Contains(dialogMessage, expected);
}
如您所见,即使格式字符串中的一个额外空格也会导致断言失败,而且由于产品总是在变化,依赖于这些方法的测试持续失败也就不足为奇了。实际上,维护这样的方法是 mission impossible,而且很可能无用。
- 测试必须与开发人员进行的任何更改保持同步以维护这些功能,这对可维护性是一个巨大的挑战,特别是当测试只涉及一些关键参数,如 "accountNumber"、"decimal"、"startDate" 来验证服务器是否正确处理了之前的操作时。
- 当实际消息与预期消息不匹配时,会收到类似 "'A long message containing this and that.' doesn't contain 'A long message containing this and that。'" 的警报,而实际上只有像第二个字符串中的额外空格字符这样的细微差别,这非常令人恼火。
要断言消息是否包含所有关键字,我们可以迭代这些参数,看看它们是否包含在对象字符串中,而 C# 的方法扩展和 "params" 可以用一个方法使其极其方便。
public static bool ContainsAll(this string content, params object[] keys)
{
foreach (object k in keys)
{
if (k is decimal)
{
decimal amount = (decimal)k;
if (!content.Contains(amount.ToString("$#,##0.00")) && !content.Contains(amount.ToString("#,##0.00")) && !content.Contains("$"+amount))
{
return false;
}
}
else if (k is DateTime)
{
DateTime date = (DateTime)k;
if (!content.Contains(date.ToString("dd/MM/yyyy"))
&& !content.Contains(date.ToString("d-MMM-yyyy")))
{
return false;
}
}
else
{
if (content.IndexOf(k.ToString(), StringComparison.InvariantCultureIgnoreCase) < 0)
{
return false;
}
}
}
return true;
}
对于我的工作项目,只有 decimal 和 DateTime 键需要特殊处理,因为它们总是转换为两种格式,并且都可以接受。对于其他类型的数据,将使用默认的 ToString(),并为简单起见忽略大小写。因此,之前的 AssertSomeMessage(string dialogMessage, string accountNumber, string billerName, decimal amount, DateTime startDate) 可以被 Assert.IsTrue(dialogMessage.ContainsAll(accountNumber, billerName, amount, startDate) 或 Assert.IsTrue(dialogMessage.ContainsAll("You are about to", accountNumber, billerName, amount, startDate) 替换。
还定义了另一个扩展方法来方便断言(注意我特意迭代了键以突出断言失败时缺失的信息)
public static void AssertContainsAll(this string content, params object[] keys)
{
foreach(key in keys)
{
Assert.IsTrue(content.ContainsAll(key));
}
}
现在,对于之前对 "AssertSomeMessage(dialogMessage, accountNumber, billerName, amount, startDate)" 的调用,可以被 "dialogMessage.AssertContainsAll(accountNumber, billerName, amount, startDate)" 或 "dialogMessage.AssertContainsAll(accountNumber, amount)" 替换,Visual Studio 会突出显示哪个关键字缺失,从而导致断言失败。
评估表格的行
从表格中选择一行也会导致许多测试失败。这些测试的设计者开发了一整套 HtmlTable、HtmlRow、HtmlCell 甚至一些 TableColumnAttribute 的包装器,通过为 EACH 列的属性定义列索引属性,以及可能成千上万个访问单元格内容或执行匹配/点击/输入等操作的属性/函数,来访问表格每行中的特定列,总共有 200 多个类。再次,将代码与如此大量的代码进行产品实现的细节耦合没有任何好处:每当产品更改表的布局时,都必须手动调整列索引,以便列类能够正确映射。
最初,我计划采用我之前关于 WebDriver 包装器的文章中讨论的方法,即通过 rowIndex 和 columnIndex/columnHeader 访问每个单元格。然而,这仍然意味着我需要进行大量的编码。然后我问自己:假设我们只是想通过关键字定位一行,是否真的有必要先定位到某些单元格,然后提取其中的确切文本?实际上,将一行内容作为一个整体来处理,足以更方便地实现这一目标。
实际上,从表格中查找一行非常类似于评估一个对话框以查看它是否包含所有预期的关键字,因此使用 string.ContainsAll(params object[] keys) 匹配行可以通过一个扩展方法对 HtmlTable 完成大部分工作。
public static HtmlRow FindRow(this HtmlTable table, params object[] keys) { table.TopParent.WaitForControlReady(60000); int rowCount = table.RowCount; for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { HtmlRow row = table.Rows[rowIndex] as HtmlRow; if (row != null && row.InnerText.ContainsAll(keys)) return row; } throw new InvalidOperationException("Row could not be found meeting expected criteria."); }
与其比较特定单元格的文本,不如提取整行的 InnerText,如果它包含某些唯一字符串(例如,用于修复失败测试的账号),仍然可以区分一行。因此,由于以前 UIMap 的 Table 定义仍然可以用来查找正确的 HtmlTable 实例,我可以放弃并保持以前的 Row/Column 定义不变,用像这样的单行代码替换以前复杂的函数调用。
SomeTableDefinedInUIMap.FindRow("AccountNumber", ...).Click();
值得注意的是,FindRow() 函数使用 row.InnerText 进行比较,因为 CodedUI 的 HtmlElement 不暴露 DisplayText 等属性。除了表格上显示的文本,InnerText实际上可能包含一些 JavaScript 代码,但这并不会改变匹配结果,即使产品布局发生剧烈变化。此外,作为一个非常通用的函数,很容易在此函数中添加更多逻辑,例如:
table.TopParent.WaitForControlReady(60000)
" 这意味着浏览器将等待 1 分钟以加载页面,以使这些操作更加可靠,当有数百个类似函数需要维护时,这将非常困难。
在获得正确的行后,通常点击它可以使其在我的项目中被选中。然而,也许出于调试目的,有一个且只有一个表格定义了行的第一个单元格内的单选按钮的 onClick(),而不是行本身。在这种情况下,仍然可以通过额外的步骤将点击单选按钮包装到单元格中。
public void SelectFromAccount(string fromAcc, string nextPayDateString="")
{
HtmlRow fromRow = SomeTableDefinedInUIMap.FindRow(fromAcc, nextPayDateString);
HtmlRadioButton radioBtn = new HtmlRadioButton(fromRow);
radioBtn.Find();
radioBtn.ClickByScript();
}
通过将 radioBtn 定义为 new HtmlRadioButton(fromRow) 而不是 new HtmlRadioButton(someCellWithinFromRow),CodedUI 仍然可以找到它,即使没有 UIMap.designer.cs 中提供的信息。ClickByScript(this HtmlControl control) 的最后一行是另一个有用的扩展方法,用于替换有问题的 Mouse.Click() 或 HtmlControl.Click(),我将在本文的最后部分详细阐述我的考虑和机制。
还有很多测试严重依赖于一些动态信息。例如,有许多函数被定义为不仅通过匹配账号来查找行,还通过比较未来的一些日期。对于更复杂的行查找,也可以应用此函数:
public static HtmlRow FindRow(this HtmlTable table, Predicate<HtmlRow> predicate)
{
int rowCount = table.RowCount;
for (var rowIndex = 0; rowIndex < rowCount; rowIndex++)
{
HtmlRow row = table.Rows[rowIndex] as HtmlRow;
if (row != null && predicate(row))
return row;
}
throw new InvalidOperationException("Row could not be found meeting expected criteria.");
}
调用此函数的示例看起来像这样:
var fromRow = table.FindRow(row => {
if (!row.InnerText.Contains(fromAcc))
return false;
HtmlCell nextDueDateCell = new HtmlCell(row);
nextDueDateCell.SearchProperties.Add(HtmlCell.PropertyNames.ColumnIndex, "2", PropertyExpressionOperator.EqualTo);
DateTime nextDueDate = new DateTime();
var result = nextDueDateCell.TryFind() && DateTime.TryParse(nextDueDateCell.InnerText, out nextDueDate)
&& nextDueDate >= expectedDate;
return result;
});
值得注意的是,在此函数中,包含日期的单元格的索引固定为 "2"。因此,如果表格发生更改,调用它的测试可能会失败,除非相应地更新方法,因此对我来说,这不是一个好习惯,我已经设法从数据挖掘中获取特定的未来日期作为额外的关键字来调用之前的 FindRow(),如下所示:
SomeTable.FindRow(account, specificDate)
关闭对话框
模态对话框窗口在 CodedUI 测试中总是存在问题。实际上,运行 CodedUI 测试的 PC 可能会因为意外出现但未关闭的对话框而挂起,此时 CodedUI.Playback.PlaybackSettings 的 SearchTimeoutMinutes 设置将无法正常工作,只有当 .testsettings 文件中的测试超时设置能够使测试控制器/代理意识到涉及的测试已超时时。您可以在您的 PC 上验证此 bug:当一个模态对话框未关闭时,后续操作将一直等待直到测试超时。
在许多情况下,测试需要在模拟用户操作之前关闭一些警告对话框。官方上,Microsoft 会建议使用大量生成的类来记录关闭这些对话框的过程;这再次不是修复只需要关闭这些对话框的测试的有效方法。
《拦截和管理窗口...》一文提出了一种更好的方法,如果我从头开始开发一个项目,我会更倾向于使用一些静态方法/监听器来实现这一目标。但要修复现有的失败测试,调用一个通用函数可能更方便。
public static bool DismissDialog(int waitMillis = 5*1000)
{
WinControl dialog = new WinControl();
dialog.SearchProperties[UITestControl.PropertyNames.ControlType] = "Dialog";
dialog.SearchProperties[UITestControl.PropertyNames.ClassName] = "#32770";
//dialog.WindowTitles.Add("Title of the dialog");
if (dialog.WaitForControlExist(waitMillis))
{
Playback.Wait(5 * 1000);
TheBrowserInstance.PerformDialogAction(Microsoft.VisualStudio.TestTools.UITest.Extension.BrowserDialogAction.Ok);
return true;
}
return false;
}
要获取当前浏览器,您可以修改代码以引用任何 activeControl.topParent,然后调用此方法将在 5 秒内关闭出现的对话框窗口。要关闭多个对话框,您可以简单地使用计时器和循环在特定时间内关闭任何匹配的对话框。
使用 SQL 模板查询/创建测试数据
我工作的项目的一个优点是,测试数据(如账号、用户 ID、计划交易日期等)是通过查询 SQL 数据库动态生成的,而不是从 .csv 文件中获取静态数据。然而,数据库中的原始数据可能已过时,因此数据挖掘会失败:例如,大约有 10 个测试失败,因为它们需要一个未来有计划交易的账号,但查询几个月前的数据总是返回 0 行。因此,这些测试的查询必须遵循以下步骤:
1) 正常查询,如果确实存在合格数据,或者有足够合格的数据,则立即返回;
2) 否则,选择一些候选行,然后用仅修改了一小部分列的新行生成,并插入到相关表中,然后删除原始行(必须修改一些键,所以更新方法不起作用)。然后再次正常查询,就会得到合格的数据。
因为我无法简单地更新候选行,因为一些键需要更改,而保持 100 多个列不变,而且我还需要将这两种情况合并到单个查询中,所以最好使用模板来修改所有现有查询,并使用临时表和滚动游标来组合下面的模板,以使这项任务更容易。
DECLARE @resultCount int
SET @resultCount = 10
IF OBJECT_ID('TempDB..#resultTable') IS NOT NULL
DROP TABLE #resultTable
IF OBJECT_ID('TempDB..#tempTable') IS NOT NULL
DROP TABLE #tempTable
/*First, run query as usual, but save the result to a temp table*/
SELECT TOP (@resultCount)
table1.key1
, table1.key2
, table2.account
, table2.key3
, table2.key4
, table2.nextTransactionDate
INTO #resultTable
from table1
inner join table3 on table1.key1 = table3.key1
inner join table2 on table2.key2 = right(table1.key2, 16)
WHERE
cast(table2.nextTransactionDate-1 as datetime) > sysdatetime()
and (table2.term_date = 0 or cast(table2.term_date-1 as datetime)> getdate())
-- A lot of other select criteria
GROUP BY table1.key2, table1.key1, table2.account, table2.key3, table2.key4, table2.key5, table2.key6, table2.nextTransactionDate
--SELECT * FROM #resultTable
BEGIN TRANSACTION
/* If there is no qualified data, or there are not enough qualified data, begin updating database to generate expected test data */
IF CAST( (SELECT count(*) FROM #resultTable) as int) <> @resultCount -- In case there are not enough qualified data
--IF NOT EXISTS (SELECT * FROM #resultTable) -- In case there is no qualified data
BEGIN
/* Using the #resultTable to store the items to be modified*/
-- Step 1) clear #resultTable
DELETE FROM #resultTable;
-- Step 2) fetch candidate data to #resultTable
INSERT INTO #resultTable
SELECT TOP (@resultCount)
table1.key1
, table1.key2
, table2.account
, table2.key3
, table2.key4
, table2.nextTransactionDate
FROM table1
inner join table3 on table1.key1 = table3.key1
inner join table2 on table2.key2 = right(table1.key2, 16)
WHERE
/* Then use different select criteria to get data that can be modified afterwards*/
/*To get some existing MONTHLY records that are outdated and with no term_date specified */
cast(table2.nextTransactionDate-1 as datetime) < sysdatetime()
AND DAY(CAST(table2.nextTransactionDate as datetime)) < 29 --For simplicity, just take those records with day of the nextTransactionDate is less than 29
and table2.term_date = 0
AND table2.FREQ = 0x3031 --Monthly Payment
-- A lot of other select criteria
GROUP BY table1.key2, table1.key1, table2.account, table2.key3, table2.key4, table2.key5, table2.key6, table2.nextTransactionDate
--SELECT * FROM #resultTable
-- Step 3) Create a temp table #tempTable to keep the rows need to be changed
SELECT * INTO #tempTable from table2 WHERE 1=0
-- Step 4) Declare variables to keep the original items that would be updated later
DECLARE @key3 binary(3)
DECLARE @key4 binary(8)
DECLARE @key5 binary(8)
DECLARE @key6 binary(8)
DECLARE @nextTransactionDate decimal(9,0)
-- Step 5) Scroll Cursor is used to iterate target table2 rows
DECLARE cur SCROLL CURSOR FOR
SELECT key3, key4, key5, key6, nextTransactionDate FROM #resultTable
OPEN cur
FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
-- Step 6) Iterate the table to be modified and keep a copy of the target rows to #tempTable
/* ********Notice: all rows involved will be reserved in this way !!!!!!!!!!!! ********* */
WHILE @@FETCH_STATUS = 0 BEGIN
-- Get the copy of table2 record that is uniquely identified
INSERT INTO #tempTable
SELECT * FROM table2
WHERE table2.key3 = @key3 AND table2.key4 = @key4 AND table2.key5 = @key5 AND table2.key6 = @key6
FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
END
--SELECT cast(key4 as varchar) AS OLD_PROS_DAY, cast(nextTransactionDate as datetime) AS OLD_NEXT_PAY, * FROM #tempTable
-- Step 7) Declare variables to store values to be used to replace the original values
DECLARE @dayDiff decimal(9,0)
DECLARE @newNextPayDate decimal(9,0)
DECLARE @firstOfNextMonth decimal(9,0)
SET @firstOfNextMonth = CAST(DATEADD(month, DATEDIFF(month, 0, GETDATE()) + 1, 0) as decimal(9,0));
--SELECT @firstOfNextMonth, convert(datetime, @firstOfNextMonth, 112)
-- Step 8) Use the same curson to iterate rows kept in #tempTable
FETCH FIRST FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
WHILE @@FETCH_STATUS = 0 BEGIN
-- Update the copy by changing its nextTransactionDate, YYYYMMDD
SET @dayDiff = @nextTransactionDate - cast(cast(cast(@key4 as varchar) as datetime) as decimal(9,0));
SET @newNextPayDate = @firstOfNextMonth + DAY(CAST(@nextTransactionDate as datetime)) - 1;
--SELECT @dayDiff as DAY_DIFF, @newNextPayDate as NEW_NEXT
-- Step 9) Change the data within #tempTable to desired ones
UPDATE #tempTable
SET nextTransactionDate = @newNextPayDate
, key4 = cast(convert(varchar(MAX), Cast(@newNextPayDate-@dayDiff as datetime), 112) as binary(8))
WHERE key3 = @key3 AND key4 = @key4 AND key5 = @key5 AND key6 = @key6
-- Step 10) Delete original row from the affected table
DELETE FROM table2
WHERE key3 = @key3 AND key4 = @key4 AND key5 = @key5 AND key6 = @key6
FETCH NEXT FROM cur INTO @key3, @key4, @key5, @key6, @nextTransactionDate
END
--SELECT cast(key4 as varchar) AS NEW_PROS_DAY, cast(nextTransactionDate as datetime) AS NEW_NEXT_PAY, * FROM #tempTable
-- Step 11) Don't forget to release the resources
CLOSE cur
DEALLOCATE cur
-- Step 12) If everything's fine, insert the modified rows back to the target table
INSERT INTO table2 SELECT * FROM #tempTable
END
-- Step 13) Now query+delete+insert all happened as expected, commit the transaction
COMMIT
--ROLLBACK
-- Return results kept in #resultTable
SELECT KEY1, ACCOUNT, nextTransactionDate FROM #resultTable
-- Drop the temp tables
IF OBJECT_ID('TempDB..#resultTable') IS NOT NULL
DROP TABLE #resultTable
IF OBJECT_ID('TempDB..#tempTable') IS NOT NULL
DROP TABLE #tempTable
通过验证和重试来修正方法
大多数时候,我们开发函数时都期望一切按部就班地进行,不会发生任何异常情况。然而,对于自动化 Web 测试执行,最好谨慎对待这种预设,特别是当它涉及网络连接/响应、数据库可用性,甚至测试框架或 Windows 本身时。
以 enterSMS() 函数为例,它只是输入用户名和 SMS 代码(从服务器端的日志文件中获取),然后点击 Login 按钮,伪代码如下:
public virtual void EnterCode(string id)
{
var smsCode = getSmsCodeFromServer();
usernameTxt.Text = id;
smsCodeTxt.Text = smsCode;
Mouse.Click(loginBtn);
}
许多测试会在执行后续操作之前调用此函数,方式如下:
public virtual void SomeTest()
{
String id = getIdFromDataMining();
EnterCode(id);
//Now the user portal shall be displayed.
Mouse.Click(someUserPortalElement);
...
}
有时,这些测试会在尝试点击用户门户显示后才能点击的元素时抛出异常,因为输入的 SMS 代码错误,甚至 CodedUI 无法输入所有代码。在我看来,EnterCode() 方法有两个问题需要改进:
- EnterCode() 应包含一些逻辑来断言登录是否成功。
- 它还应容忍因未能获取正确代码或输入错误代码而导致的失败。
第一个目标可以通过断言某个元素在合理时间内消失来实现,第二个目标可以通过调用自身进行递归,并带有一个额外的尝试参数来实现。原始函数可以修改如下:
public virtual void EnterCode(string id, int attemp=3)
{
var smsCode = getSmsCodeFromServer();
usernameTxt.Text = id;
smsCodeTxt.Text = smsCode;
Mouse.Click(loginBtn);
//Check if the login disappear within 20s, if not, then retry or fail immediately
if (!loginBtn.WaitForControlNotExist(20*1000))
{
if ((attempts--) > 0)
{
//We can still try again by calling EnterCode() itself
EnterCode(id, attemps);
}
else
{
Assert.Fail("Login failed!");
}
}
}
这样,EnterCode() 函数最多可以运行 3 次,而无需修改其调用者,如果登录失败,测试结果会显示正确的原因。
TryFind() 与 WaitForControlXXX()
从我工作的原始测试来看,有很多调用 TryFind() 改变了测试执行。原因是 TryFind() 在页面仍在加载时会立即返回 true/false,其返回值将严重依赖于 Web 服务器的响应能力,因此一些测试可以通过简单地将其替换为 WaitForControlExist() 来修复,该函数将阻塞当前线程直到默认的 PlaybackSettings.WaitForReadyTimeout 指定的超时时间。
我个人更喜欢通过调用 WaitForControlReady(int millisecondsTimeout) 或 WaitForControlExist(int millisecondsTimeout) 来显式指定超时设置。有人可能习惯于 Playback.Wait(int thinkTimeMilliseconds) 或 Thread.Sleep(int millisecondsTimeout),但他们可能会等待不必要的时间。此外,如果与断言结合使用,如下所示:
Assert.IsTrue(someElement.WaitForControlReady(60*1000)); //Wait for 1 min
意外的延迟可以立即被突出显示。
UITestControl 定义了多个 WaitForControlXXX() 函数,其中一些可能非常有帮助但通常被忽略,例如:
- WaitForControlCondition(Predicate<UITestControl> conditionEvaluator, int millisecondsTimeout),结合 LINQ,提供了一种非常强大的方法来评估目标控件的任何状态。
- WaitForControlPropertyEqual(string propertyName, object propertyValue, int millisecondsTimeout) 使测试人员能够有效地监视控件的任何属性的变化。
- bool WaitForControlNotExist(int millisecondsTimeout),结合 Assert.IsTrue(),可用于评估操作是否已成功导致页面更改为另一种状态。
- 在执行点击、选择或输入等稳健操作之前,应不时使用 bool WaitForControlExist(int millisecondsTimeout) 和 bool WaitForControlReady(int millisecondsTimeout)。通常,前者已足够,特别是当这些操作只会在浏览器/控件准备好时执行。但它们可能对特定控件返回不同的值:通常 WaitForControlExist() 在目标控件显示时返回 true,但在我的一些项目中,WaitForControlReady() 在 WaitForControlExist() 返回 true 几分钟后才返回 true。
使用 JavaScript 进行操作
最初,当我注意到许多测试会在页面加载几分钟后才开始第一次操作(例如从组合框中选择一个选项,点击链接或按钮等)时,我试图使用 JavaScript 来加快测试执行速度。当从我的 PC 上运行测试时,这很难容忍,我只能等待几分钟才能运行一行代码。在这些情况下,虽然 CodedUI 无法前进,但我可以直接从 IE 浏览器的命令行运行 JavaScript。因此,在我意识到 BrowserWindow 只有在 WaitForControlReady() 返回 true 后才会执行 JavaScript 之前,我尝试了多种方法来使其运行。幸运的是,我开发 JavaScript 来替换 Mouse.Click() 等原生操作的工作仍然非常有帮助。
有时,CodedUI 测试在执行一些基本操作(如点击按钮或向文本控件输入文本)时会失败。我个人认为这是由于 Windows 而不是 CodedUI 造成的:有时当我点击网页上的某一行/按钮时,我可以听到“点击”的声音,但浏览器根本不做任何事情。当 JavaScript 直接操作元素而不是通过 UI 时,它会更有效。
我编写的关键脚本如下:
#region JavaScript function names
public const string FindByCssFunction = "querySelector";
public const string FindByIdFunction = "getElementById";
public const string FindFirstByCssFunction = "querySelectorAll";
public const string FindFirstByClassFunction = "getElementsByClassName";
public const string FindFirstByNameFunction = "getElementsByName";
public const string FindFirstByTagFunction = "getElementsByTagName";
#endregion
public const string GetElementByIdScript = @"
var result = document.getElementById(arguments[0]);
if (result)
return result;
var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
var frame = frames[arguments[1]];
if (frame.document)
return frame.document.getElementById(arguments[0]);
else
return frame.contentWindow.document.getElementById(arguments[0]);
}
for(var i = 0; i < frames.length; i ++) {
if (frames[i].document)
result = frames[i].document.getElementById(arguments[0]);
else
result = frames[i].contentWindow.document.getElementById(arguments[0]);
if (result) break;
}
return result;";
public const string FrameGetElementByIdScript = @"
var result = arguments[0].contentDocument.getElementById(arguments[1]);
return result;";
public const string GetFirstByCssScript = @"
var elements = document.querySelectorAll(arguments[0]);
if (elements.length)
return elements[0];
var frames = document.getElementsByTagName('frame');
if (arguments[1]) {
var frame = frames[arguments[1]];
if (frame.document)
return frame.document.querySelectorAll(arguments[0]);
else
return frame.contentWindow.document.querySelectorAll(arguments[0]);
}
for(var i = 0; i < frames.length; i ++) {
if (frames[i].document)
elements = frames[i].document.querySelectorAll(arguments[0]);
else
elements = frames[i].contentWindow.document.querySelectorAll(arguments[0]);
if (elements.length)
return elements[0];
}
return null;";
public const string GetClickablePoint = @"
var element = arguments[0];
var absoluteLeft = element.width/2;
var absoluteTop = element.height/2;
do {
absoluteLeft += element.offsetLeft;
absoluteTop += element.offsetTop;
element = element.parentElement;
}while(element)
var result = new Array();
result[0] = Math.round(absoluteLeft).toString();
result[1] = Math.round(absoluteTop).toString();
return result;";
public const string GetAttributeScript = @"try{{return arguments[0].getAttribute('{0}');}}catch(err){{return null;}}";
public enum FindFirstMethod
{
ById,
ByCSS,
FirstByCSS,
FirstByClass,
FirstByName,
FirstByTag,
}
被测 Web 应用使用多个框架作为容器来显示不同的面板。要按 ID 搜索一个元素,JavaScript 的 "GetElementByIdScript" 将首先搜索根文档,然后获取所有框架,并尝试迭代所有框架,看看是否能在其中一个框架中找到具有指定 ID 的元素。 "FrameGetElementByIdScript" 用于仅根据元素 ID 搜索单个框架。 "GetFirstByCssScript" 提供了一种更通用的方法来使用 CSS 选择器搜索元素,尽管它通常会返回一个数组,并且实际上只期望第一个。 "GetAttributeScript" 用于检索给定元素的属性,请注意,当捕获到错误时,它应返回 "null"。
为了使用上述脚本(通过 ID、Class、Name 等)的多种方法,定义了 FindFirstMethod 枚举,并使用 ById 作为默认值,如下所示:
public static HtmlControl FindControl(this BrowserWindow window, string locatorKey, FindFirstMethod method = FindFirstMethod.ById, string frameName = "body")
{
if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");
string script = null;
switch (method)
{
case FindFirstMethod.ById:
script = GetElementByIdScript;
break;
case FindFirstMethod.ByCSS:
script = GetElementByIdScript.Replace(FindByIdFunction, FindByCssFunction);
break;
case FindFirstMethod.FirstByCSS:
script = GetFirstByCssScript;
break;
case FindFirstMethod.FirstByClass:
script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByClassFunction);
break;
case FindFirstMethod.FirstByName:
script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByNameFunction);
break;
case FindFirstMethod.FirstByTag:
script = GetFirstByCssScript.Replace(FindFirstByCssFunction, FindFirstByTagFunction);
break;
default:
throw new NotSupportedException();
}
object result = null;
Stopwatch watch = new Stopwatch();
watch.Start();
while (result == null && watch.ElapsedMilliseconds < 20 * 1000)
{
result = window.ExecuteScript(script, locatorKey, frameName);
//To cope with the bug of BrowserWindow..ExecuteScript()
var optionList = result as IList<object>;
if (optionList != null)
{
var child = optionList.FirstOrDefault(o => o != null) as HtmlControl;
result = (child == null || !child.Exists ) ? null : child.GetParent();
}
}
return result as HtmlControl;
}
然后,脚本将通过替换一些关键字来完成,并由 BrowserWindow 实例运行。值得注意的是,在尝试获取 HtmlComboBox 时,CodedUI 会错误地返回其选项子项的数组,这就是为什么需要进行一些特殊处理才能获取单个 HtmlControl。
CodedUI 只定义了带有虚拟对象 ExecuteScript(string script, params object[] args) 的 BrowserWindow,为了便于使用,引入了两个辅助方法来定位一个特定控件,带或不带额外参数:
public static object RunScript(this HtmlControl control, string script)
{
if (control == null)
throw new Exception("Failed to locating the control?!");
BrowserWindow window = control.TopParent as BrowserWindow;
if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");
return window.ExecuteScript(script, control);
}
public static object RunScript(this HtmlControl control, string script, params object[] extraArguments)
{
if (control == null)
throw new Exception("Failed to locating the control?!");
BrowserWindow window = control.TopParent as BrowserWindow;
if (window == null || !window.WaitForControlReady(DefaultWaitReadyTimeMillis))
throw new Exception("Browser is not specified or is not ready after " + DefaultWaitReadyTimeMillis / 1000 + "s.");
var len = extraArguments.Length;
object[] arguments = new object[len + 1];
arguments[0] = control;
for (int i = 0; i < len; i++)
{
arguments[i + 1] = extraArguments[i];
}
return window.ExecuteScript(script, arguments);
}
然后一些辅助函数非常直接:
public static string AttributeByScript(this HtmlControl control, string attributename)
{
return control.RunScript(string.Format(GetAttributeScript, attributename)) as string;
}
public static string InnerText(this HtmlControl control)
{
return control.RunScript("return arguments[0].innerText;") as string;
}
public static string InnerHTML(this HtmlControl control)
{
return control.RunScript("return arguments[0].innerHtml;") as string;
}
public static string OuterHTML(this HtmlControl control)
{
return control.RunScript("return arguments[0].outerHtml;") as string;
}
public static Point ClickablePointByScript(this HtmlControl control)
{
object result = control.RunScript(GetClickablePoint);
List<object> position = (List<object>)result;
return new Point(int.Parse(position[0].ToString()), int.Parse(position[1].ToString()));
}
它们的含义如下:
- SomeControl.AttributeByScript(attributename): 按属性名称获取控件的属性;
- SomeControl.InnerText(): 将检索控件的开闭标签内的所有内容作为 innerText,如“评估表格的行”部分所示。可以调整它以获取显示文本。
- SomeControl.InnerHtml()/OuterHtml(): 分别返回 InnerHtml 和 OuterHtml。
- SomeControl.ClickablePointByScript(): 我尝试过使用它来获取可点击点,以避免等待 CodedUI 准备好点击某个链接/按钮,但它在目标控件真正准备好之前不会返回。
更有用的方法列在这里:
public const bool HighLightControlBeforeOperation = true;
public static void ClickByScript(this HtmlControl control)
{
control.ShowByScript();
if (HighLightControlBeforeOperation)
control.HighlightByScript();
control.RunScript("arguments[0].click();");
}
public static void SetValue(this HtmlControl control, string valueString)
{
control.RunScript("arguments[0].value = arguments[1];", valueString);
}
public static void ShowByScript(this HtmlControl control)
{
control.RunScript("arguments[0].scrollIntoView(true);");
}
public const string DefaultHighlightStyle = "color: green; border: solid red; background-color: yellow;";
public static void HighlightByScript(this HtmlControl control)
{
//*/ Highlight by script: changing the style of concerned element
var oldStyle = control.AttributeByScript("style");
control.RunScript("arguments[0].setAttribute('style', arguments[1]);", DefaultHighlightStyle);
System.Threading.Thread.Sleep(DefaultHighlightTimeMillis);
if (oldStyle != null)
{
control.RunScript("arguments[0].setAttribute('style', arguments[1]);", oldStyle);
}
else
control.RunScript(string.Format("arguments[0].removeAttribute('style');"));
}
它们的含义如下:
- SomeEdit.SetValue(valueString): 用于向 Edit 控件输入文本,即使它还没有显示。
- SomeControl.ShowByScript(): 将使控件可见,以便进行进一步操作/观察。
- SomeControl.HighlightByScript(): 修改目标控件的样式,使其高亮显示几秒钟。
- SomeControl.ClickByScript(): 可能是修复测试最有用的方法。将调用 ShowByScript() 使控件可见,然后调用 HighlightByScript() 使其突出显示,然后执行“someControl.click()”。这个设计是出乎意料的:通过执行 3 次操作(滚动一次,更改样式两次),非常不可能错过点击目标控件。
使用这些脚本也相当简单,以 Click() 为例:对于一些有问题的 Mouse.Click(someControl),只需将其替换为 "someControl.ClickByScript()" 就可以让许多失败的测试通过。
关注点
本文讨论的方法也可以应用于其他测试框架。您可以看到,将一些基本操作包装成扩展方法而不是定义特定于元素/控件的函数,可能会更有效。