自動化UI測試(UI自動化、Appium、編碼UI)
用戶界面 (UI) 測試可驗證應用程序的所有視覺元素是否正常運行。UI測試可以由測試人員手動執(zhí)行,也可以借助自動化測試工具執(zhí)行,自動化測試更快、更可靠且更具成本效益。
微軟編碼UI測試(CUIT)框架
編碼UI測試框架是微軟的一個解決方案,它利用控件的可訪問性層來記錄和運行UI測試,CUIT組件通過Visual Studio Installer分發(fā)。
該解決方案在Visual Studio 2019及以后被宣布過時,在Visual Studio 2022中,您仍然可以運行已編碼UI測試,但不能記錄新測試,較新的IDE版本將完全放棄對CUIT的支持。
參見:
DevExpress編碼UI擴展
DevExpress Coded UI是Microsoft Coded UI Tests的擴展,專為基于DevExpress的應用程序量身定制。這些解決方案之間的區(qū)別在于與Microsoft CUIT不同,DevExpress編碼UI擴展不利用輔助功能,該框架通過專有通道與控件進行通信,并使用DevExpress控件中聲明的幫助程序類。
Microsoft 終止CUIT的決定也會影響DevExpress編碼UI擴展,對于較新的項目,我們建議您改用Appium或UI Automation。
也可以看看:
Appium和UI自動化
Appium是一款開源工具,可讓您為 Web、混合、iOS 移動、Android 移動和 Windows 桌面平臺創(chuàng)建自動化UI測試,要測試Windows應用程序則需要設置WinAppDriver 。
也可以看看:
- Windows 驅(qū)動程序 — Appium 文檔。
- 從 CodedUI 遷移到 Appium — 帶有示例的 DevExpress 博客文章。
Appium(以及多個其他測試框架)利用UI Automation ——Microsoft 的Windows輔助功能框架,您可以直接使用此框架(不涉及任何第三方解決方案)來編寫UI測試。
也可以看看:
- UI 自動化 — 來自 Microsoft 的概述文章。
Appium和UI Automation 之間的選擇取決于場景和測試要求的復雜性,Appium更容易使用,但也有更多限制,因為它沒有實現(xiàn)所有UIA功能。例如,Appium 允許您使用pattern 成員,但只能使用屬性,不能使用方法。
提示:調(diào)度程序、富編輯器、PDF查看器和電子表格控件目前不支持UI自動化。
步驟記錄器和手動測試腳本
大多數(shù)測試自動化平臺都提供了記錄工具,這些工具在運行時跟蹤您的操作(光標移動、單擊和鍵盤按鍵),并生成模擬這些操作的代碼。下面的博客文章展示了如何使用Appium步進記錄器與DevExpress控件:從CodedUI移動到appium。
記錄器允許您編寫更少的代碼,但它們可能產(chǎn)生不穩(wěn)定的測試并導致性能問題。例如,大多數(shù)測試記錄器在元素選擇代碼中枚舉目標UI元素的所有父元素,因此,一個小的UI修改(比如添加一個新的Panel容器)會導致這個選擇代碼失敗。
為了避免潛在的問題并更好地理解測試的功能,我們建議手動編寫測試腳本。例如,您可以選擇為目標UI元素檢查哪些父控件,而不是列出元素父元素的整個層次結(jié)構(gòu),或者直接獲取該元素而不訪問其任何父元素。
如何編寫Appium和UI自動化測試
常用測試結(jié)構(gòu)
Appium和UI自動化測試共享類似的代碼塊層次結(jié)構(gòu),每個塊都由一個 NUnit屬性裝飾。
修飾包含測試的類。
每次測試即將開始時,都會調(diào)用帶有此屬性的方法。
與SetUp屬性相反,此屬性修飾每次測試完成時執(zhí)行的一組指令。
修飾一個包含測試腳本的方法。
Appium和UIA測試的一般實現(xiàn)如下所示:
C#:
using System; using NUnit.Framework; namespace VisualTests { [TestFixture] public class MyAppTests { [SetUp] public void Setup() { // Actions repeated before each test } [TearDown] public void Cleanup() { // Actions repeated after each test } [Test] public void Test1() { // Test #1 } [Test] public void Test2() { // Test #2 } } }
VB.NET:
Imports System Imports NUnit.Framework Namespace VisualTests <TestFixture> Public Class MyAppTests <SetUp> Public Sub Setup() ' Actions repeated before each test End Sub <TearDown> Public Sub Cleanup() ' Actions repeated after each test End Sub <Test> Public Sub Test1() ' Test #1 End Sub <Test> Public Sub Test2() ' Test #2 End Sub End Class End Namespace
檢查Tool
要為任何UI元素編寫測試,需要做以下事情:
- 通過ID或名稱獲取該元素。
- 檢查它支持哪些模式,并利用這些模式的屬性和方法來模擬用戶操作。
- 調(diào)用Assert.AreEqual 方法來比較實際和預期的控制狀態(tài)。
要獲取元素名稱和 ID,并檢查其可用的模式 API,請使用Microsoft Inspect —— Windows SDK安裝中包含的免費工具。
手工檢查UI元素還允許您定位不良的可訪問性名稱和其他問題,要解決這些問題,請?zhí)幚鞤XAccessible.QueryAccessibleInfo事件。
如何編寫 Appium 測試
- 在 Windows 設置中啟用Developer Mode。
- 下載、安裝并運行WinAppDriver 。
- 在需要測試的項目中打開全局WindowsFormsSettings.UseUIAutomation。
- 在 Visual Studio 中創(chuàng)建一個新的“單元測試項目” 。
- 安裝“Appium.WebDriver” NuGet 包。
- 根據(jù)通用測試結(jié)構(gòu)部分創(chuàng)建測試,下面的代碼說明了一個自動化測試示例。
C#:
using System; using System.Windows.Forms; using NUnit.Framework; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; namespace AppiumTests { [TestFixture] public class EditorsDemoTests { WindowsDriver<WindowsElement> driver; string editorsDemoPath = @"C:\Work\2022.1\Demos.Win\EditorsDemos\CS\EditorsMainDemo\bin\Debug\EditorsMainDemo.exe"; [SetUp] public void Setup() { AppiumOptions options = new AppiumOptions(); options.AddAdditionalCapability("app", editorsDemoPath); driver = new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), options); } [TearDown] public void Cleanup() { driver.Close(); } [Test] public void ProgressBarTest() { var form = driver.FindElementByAccessibilityId("RibbonMainForm"); var progressBarAccordionItem = form.FindElementByAccessibilityId("accordionControl1").FindElementByName("Progress Bar"); progressBarAccordionItem.Click(); Assert.AreEqual("True", progressBarAccordionItem.GetAttribute("SelectionItem.IsSelected")); AccessibleStates itemStates = (AccessibleStates)int.Parse(progressBarAccordionItem.GetAttribute("LegacyState")); Assert.IsTrue(itemStates.HasFlag(AccessibleStates.Selected)); form.FindElementByName("Position Management").Click(); var minMaxComboBox = form.FindElementByAccessibilityId("comboBoxMaxMin"); minMaxComboBox.Click(); minMaxComboBox.SendKeys( OpenQA.Selenium.Keys.Down + OpenQA.Selenium.Keys.Down + OpenQA.Selenium.Keys.Enter); Assert.AreEqual("Min = 100; Max = 200", minMaxComboBox.Text); var progressBar = form.FindElementByAccessibilityId("progressBarSample2"); Assert.AreEqual("100", progressBar.GetAttribute("RangeValue.Minimum")); Assert.AreEqual("200", progressBar.GetAttribute("RangeValue.Maximum")); Assert.AreEqual("100", progressBar.GetAttribute("RangeValue.Value")); Assert.AreEqual("0%", progressBar.Text); form.FindElementByName("Step!").Click(); Assert.AreEqual("110", progressBar.GetAttribute("RangeValue.Value")); Assert.AreEqual("10%", progressBar.Text); } } }
VB.NET:
Imports System Imports System.Windows.Forms Imports NUnit.Framework Imports OpenQA.Selenium.Appium Imports OpenQA.Selenium.Appium.Windows Namespace AppiumTests <TestFixture> Public Class EditorsDemoTests Private driver As WindowsDriver(Of WindowsElement) Private editorsDemoPath As String = "C:\Work\2022.1\Demos.Win\EditorsDemos\CS\EditorsMainDemo\bin\Debug\EditorsMainDemo.exe" <SetUp> Public Sub Setup() Dim options As New AppiumOptions() options.AddAdditionalCapability("app", editorsDemoPath) driver = New WindowsDriver(Of WindowsElement)(New Uri("http://127.0.0.1:4723"), options) End Sub <TearDown> Public Sub Cleanup() driver.Close() End Sub <Test> Public Sub ProgressBarTest() Dim form = driver.FindElementByAccessibilityId("RibbonMainForm") Dim progressBarAccordionItem = form.FindElementByAccessibilityId("accordionControl1").FindElementByName("Progress Bar") progressBarAccordionItem.Click() Assert.AreEqual("True", progressBarAccordionItem.GetAttribute("SelectionItem.IsSelected")) Dim itemStates As AccessibleStates = CType(Integer.Parse(progressBarAccordionItem.GetAttribute("LegacyState")), AccessibleStates) Assert.IsTrue(itemStates.HasFlag(AccessibleStates.Selected)) form.FindElementByName("Position Management").Click() Dim minMaxComboBox = form.FindElementByAccessibilityId("comboBoxMaxMin") minMaxComboBox.Click() minMaxComboBox.SendKeys(OpenQA.Selenium.Keys.Down + OpenQA.Selenium.Keys.Down + OpenQA.Selenium.Keys.Enter) Assert.AreEqual("Min = 100; Max = 200", minMaxComboBox.Text) Dim progressBar = form.FindElementByAccessibilityId("progressBarSample2") Assert.AreEqual("100", progressBar.GetAttribute("RangeValue.Minimum")) Assert.AreEqual("200", progressBar.GetAttribute("RangeValue.Maximum")) Assert.AreEqual("100", progressBar.GetAttribute("RangeValue.Value")) Assert.AreEqual("0%", progressBar.Text) form.FindElementByName("Step!").Click() Assert.AreEqual("110", progressBar.GetAttribute("RangeValue.Value")) Assert.AreEqual("10%", progressBar.Text) End Sub End Class End Namespace
- 上面的代碼借助FindElementByName和FindElementByAccessibilityId方法定位所需的UI元素,要獲取元素名稱或ID,請在Inspect中瀏覽元素屬性
- 要模擬鼠標單擊和按鍵,請調(diào)用Click()和SendKeys方法。
- 使用UIElement.GetAttribute方法獲取模式屬性的值,這些名稱在Inspect中也可見。
要訪問模式的屬性LegacyIAccessible,請使用“Legacy{PropertyName}”格式:
C#:
var value = progressBarAccordionItem.GetAttribute("LegacyState");
點擊復制
VB.NET:
Dim value = progressBarAccordionItem.GetAttribute("LegacyState")
點擊復制
其他模式的屬性用“{PatternName}.{PropertyName}”格式訪問:
C#:
var value = progressBar.GetAttribute("RangeValue.Maximum");
點擊復制
VB.NET:
Dim value = progressBar.GetAttribute("RangeValue.Maximum")
點擊復制
- DevExpress 上下文菜單沒有直接所有者,因此它們的可訪問對象是桌面窗口的子窗口,而不是應用程序窗口,要訪問這些菜單中的項目,請使用桌面窗口驅(qū)動程序。
C#:
AppiumOptions globalDriverOptions = new AppiumOptions(); globalDriverOptions.AddAdditionalCapability("app", "Root"); var globalDriver = new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), globalDriverOptions); var menuItem = globalDriver.FindElementByName("ItemName");
點擊復制
VB.NET:
Dim globalDriverOptions As AppiumOptions = New AppiumOptions() globalDriverOptions.AddAdditionalCapability("app", "Root") Dim globalDriver = New WindowsDriver(Of WindowsElement)(New Uri("http://127.0.0.1:4723"), globalDriverOptions) Dim menuItem = globalDriver.FindElementByName("ItemName")
點擊復制
如何編寫 UI 自動化測試
- 在需要測試的項目中打開全局WindowsFormsSettings.UseUIAutomation屬性。
- 在Visual Studio中創(chuàng)建一個新的“Unit Test Project”。
- 在您的項目中包括UIAutomationClient.dll和UIAutomationTypes.dll庫。
- 根據(jù)公共測試結(jié)構(gòu)部分創(chuàng)建測試,下面的代碼演示了一個自動化測試示例。
C#:
using System; using System.Diagnostics; using System.Threading; using System.Windows.Automation; using Microsoft.Test.Input; using NUnit.Framework; namespace UIAutomationTests { [TestFixture] public class OutlookInspiredTests { string path = @"C:\Work\2022.1\Demos.RealLife\DevExpress.OutlookInspiredApp\ bin\Debug\DevExpress.OutlookInspiredApp.Win.exe"; Process appProcess; [SetUp] public void Setup() { appProcess = Process.Start(path); } [TearDown] public void TearDown() { appProcess.Kill(); } [Test] public void Test1() { AutomationElement form = AutomationElement.RootElement.FindFirstWithTimeout(TreeScope.Children, new PropertyCondition( AutomationElement.AutomationIdProperty, "MainForm"), 10000); AutomationElement grid = form.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.AutomationIdProperty, "gridControl"), 5000); AutomationElement cell = FindCellByValue(grid, "FULL NAME", "Greta Sims"); Mouse.MoveTo(cell.GetPoint()); Mouse.DoubleClick(MouseButton.Left); AutomationElement detailForm = form.FindFirstWithTimeout(TreeScope.Children, new PropertyCondition( AutomationElement.AutomationIdProperty, "DetailForm"), 5000); AutomationElement jobTitleEdit = detailForm.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.AutomationIdProperty, "TitleTextEdit")); ((ValuePattern)jobTitleEdit.GetCurrentPattern(ValuePattern.Pattern)).SetValue("HR Head"); AutomationElement department = detailForm.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.AutomationIdProperty, "DepartmentImageComboBoxEdit")); ((ExpandCollapsePattern)department.GetCurrentPattern(ExpandCollapsePattern.Pattern)).Expand(); AutomationElement managementItem = detailForm.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.NameProperty, "Management")); ((InvokePattern)managementItem.GetCurrentPattern(InvokePattern.Pattern)).Invoke(); AutomationElement saveClose = detailForm.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.NameProperty, "Save & Close")); ((InvokePattern)saveClose.GetCurrentPattern(InvokePattern.Pattern)).Invoke(); AutomationElement jobTitle = form.FindFirstWithTimeout(TreeScope.Descendants, new PropertyCondition( AutomationElement.AutomationIdProperty, "sliTitle")); Assert.AreEqual("HR Head", jobTitle.Current.Name); } AutomationElement FindCellByValue(AutomationElement grid, string columnName, string cellValue) { TablePattern tablePattern = (TablePattern)grid.GetCurrentPattern(TablePattern.Pattern); AutomationElement[] headers = tablePattern.Current.GetColumnHeaders(); int columnIndex = -1; for(int i = 0; i < headers.Length - 1; i++) if(headers[i].Current.Name == columnName) columnIndex = i; if(columnIndex == -1) return null; for(int i = 0; i < tablePattern.Current.RowCount; i++) { AutomationElement cell = tablePattern.GetItem(i, columnIndex); if(cell != null) { ValuePattern valuePattern = (ValuePattern)cell.GetCurrentPattern(ValuePattern.Pattern); if(valuePattern.Current.Value == cellValue) { return cell; } } } return null; } } public static class AutomationElementExtensions { public static System.Drawing.Point GetPoint(this AutomationElement @this) { System.Windows.Point windowsPoint = @this.GetClickablePoint(); return new System.Drawing.Point(Convert.ToInt32(windowsPoint.X), Convert.ToInt32(windowsPoint.Y)); } public static AutomationElement FindFirstWithTimeout(this AutomationElement @this, TreeScope scope, Condition condition, int timeoutMilliseconds = 1000) { Stopwatch stopwatch = new Stopwatch(); stopwatch.Start(); do { var result = @this.FindFirst(scope, condition); if(result != null) return result; Thread.Sleep(100); } while(stopwatch.ElapsedMilliseconds < timeoutMilliseconds); return null; } } }
VB.NET:
Imports System Imports System.Diagnostics Imports System.Threading Imports System.Windows.Automation Imports Microsoft.Test.Input Imports NUnit.Framework Namespace UIAutomationTests <TestFixture> Public Class OutlookInspiredTests Private path As String = "C:\Work\2022.1\Demos.RealLife\DevExpress.OutlookInspiredApp\bin\Debug\DevExpress.OutlookInspiredApp.Win.exe" Private appProcess As Process <SetUp> Public Sub Setup() appProcess = Process.Start(path) End Sub <TearDown> Public Sub TearDown() appProcess.Kill() End Sub <Test> Public Sub Test1() Dim form As AutomationElement = AutomationElement.RootElement.FindFirstWithTimeout(TreeScope.Children, New PropertyCondition(AutomationElement.AutomationIdProperty, "MainForm"), 10000) Dim grid As AutomationElement = form.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.AutomationIdProperty, "gridControl"), 5000) Dim cell As AutomationElement = FindCellByValue(grid, "FULL NAME", "Greta Sims") Mouse.MoveTo(cell.GetPoint()) Mouse.DoubleClick(MouseButton.Left) Dim detailForm As AutomationElement = form.FindFirstWithTimeout(TreeScope.Children, New PropertyCondition(AutomationElement.AutomationIdProperty, "DetailForm"), 5000) Dim jobTitleEdit As AutomationElement = detailForm.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.AutomationIdProperty, "TitleTextEdit")) CType(jobTitleEdit.GetCurrentPattern(ValuePattern.Pattern), ValuePattern).SetValue("HR Head") Dim department As AutomationElement = detailForm.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.AutomationIdProperty, "DepartmentImageComboBoxEdit")) CType(department.GetCurrentPattern(ExpandCollapsePattern.Pattern), ExpandCollapsePattern).Expand() Dim managementItem As AutomationElement = detailForm.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.NameProperty, "Management")) CType(managementItem.GetCurrentPattern(InvokePattern.Pattern), InvokePattern).Invoke() Dim saveClose As AutomationElement = detailForm.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.NameProperty, "Save & Close")) CType(saveClose.GetCurrentPattern(InvokePattern.Pattern), InvokePattern).Invoke() Dim jobTitle As AutomationElement = form.FindFirstWithTimeout(TreeScope.Descendants, New PropertyCondition(AutomationElement.AutomationIdProperty, "sliTitle")) Assert.AreEqual("HR Head", jobTitle.Current.Name) End Sub Private Function FindCellByValue(ByVal grid As AutomationElement, ByVal columnName As String, ByVal cellValue As String) As AutomationElement Dim tablePattern As TablePattern = CType(grid.GetCurrentPattern(TablePattern.Pattern), TablePattern) Dim headers() As AutomationElement = tablePattern.Current.GetColumnHeaders() Dim columnIndex As Integer = -1 For i As Integer = 0 To headers.Length - 2 If headers(i).Current.Name = columnName Then columnIndex = i End If Next i If columnIndex = -1 Then Return Nothing End If For i As Integer = 0 To tablePattern.Current.RowCount - 1 Dim cell As AutomationElement = tablePattern.GetItem(i, columnIndex) If cell IsNot Nothing Then Dim valuePattern As ValuePattern = CType(cell.GetCurrentPattern(ValuePattern.Pattern), ValuePattern) If valuePattern.Current.Value = cellValue Then Return cell End If End If Next i Return Nothing End Function End Class Public Module AutomationElementExtensions <System.Runtime.CompilerServices.Extension> _ Public Function GetPoint(ByVal this As AutomationElement) As System.Drawing.Point Dim windowsPoint As System.Windows.Point = this.GetClickablePoint() Return New System.Drawing.Point(Convert.ToInt32(windowsPoint.X), Convert.ToInt32(windowsPoint.Y)) End Function <System.Runtime.CompilerServices.Extension> _ Public Function FindFirstWithTimeout(ByVal this As AutomationElement, ByVal scope As TreeScope, ByVal condition As Condition, Optional ByVal timeoutMilliseconds As Integer = 1000) As AutomationElement Dim stopwatch As New Stopwatch() stopwatch.Start() Do Dim result = this.FindFirst(scope, condition) If result IsNot Nothing Then Return result End If Thread.Sleep(100) Loop While stopwatch.ElapsedMilliseconds < timeoutMilliseconds Return Nothing End Function End Module End Namespace
- 與Appium測試類似,根據(jù)從Inspect復制的名稱或id檢索元素,使用AutomationElement.FindFirst 來查找所需的元素。
- 自定義FindFirstWithTimeout方法通過添加超時閾值來擴展FindFirst,此值指定當元素不能立即可用時,腳本可以重試獲取該元素的時間。
- 該類Mouse公開了允許模擬鼠標操作的方法,安裝“Microsoft.TestApi” NuGet 包后,此類即可使用,也可以使用其他方式來模擬單擊和指針移動。
- 模式方法(TablePattern.GetColumnHeaders()、ValuePattern.SetValue()等)允許您快速找到所需的元素、設置新的控件值、執(zhí)行默認控件操作(例如單擊)等等,正如在Appium和UI自動化一節(jié)中提到的,這些方法在Appium中不可用。
- 要獲得上下文菜單項,可以使用RootElements和TreeScope.Descendants。
C#:
AutomationElement menuItem = AutomationElement.RootElement.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.NameProperty, "itemName")); ((InvokePattern)menuItem.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
VB.NET:
Dim globalDriverOptions As AppiumOptions = New AppiumOptions() globalDriverOptions.AddAdditionalCapability("app", "Root") Dim globalDriver = New WindowsDriver(Of WindowsElement)(New Uri("http://127.0.0.1:4723"), globalDriverOptions) Dim menuItem = globalDriver.FindElementByName("ItemName")