WPF 自动化 UI 测试
大约 15 分钟约 4568 字
WPF 自动化 UI 测试
简介
WPF 自动化 UI 测试是通过程序模拟用户操作(点击、输入、拖拽等)来验证应用程序界面行为和功能的测试方法。.NET 平台提供了 UI Automation 框架作为基础设施,而 FlaUI 等开源库则在其之上提供了更友好的 API。自动化 UI 测试可以有效捕获回归问题、验证交互逻辑、保障用户体验,是质量保障体系中不可或缺的一环。
特点
UI Automation 框架基础
UI Automation 树结构
/// <summary>
/// UI Automation 的核心概念:
///
/// 1. AutomationElement — 每个 UI 元素对应一个 AutomationElement
/// 2. ControlType — 控件类型(Button、TextBox、Window 等)
/// 3. Pattern — 控件模式(Invoke、Value、Selection 等)
/// 4. Property — 元素属性(Name、ClassName、IsEnabled 等)
/// 5. TreeWalker — 遍历 UI 树的工具
///
/// UI Automation 树结构:
/// Desktop (RootElement)
/// └── Application Window
/// ├── MenuBar
/// │ └── MenuItem "File"
/// ├── TabControl
/// │ ├── TabItem "General"
/// │ └── TabItem "Advanced"
/// ├── TextBox "Username"
/// ├── PasswordBox
/// └── Button "Login"
/// </summary>
using System.Windows.Automation;
public static class UiAutomationBasics
{
/// <summary>
/// 查找主窗口
/// </summary>
public static AutomationElement FindMainWindow(string windowTitle)
{
// 获取桌面根元素
AutomationElement desktop = AutomationElement.RootElement;
// 构建查找条件
var condition = new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window),
new PropertyCondition(AutomationElement.NameProperty, windowTitle)
);
// 查找窗口(最多等待 5 秒)
return desktop.FindFirst(TreeScope.Children, condition);
}
/// <summary>
/// 查找子元素
/// </summary>
public static AutomationElement FindButton(AutomationElement parent, string buttonName)
{
var condition = new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
new PropertyCondition(AutomationElement.NameProperty, buttonName)
);
return parent.FindFirst(TreeScope.Descendants, condition);
}
/// <summary>
/// 使用 TreeWalker 遍历 UI 树
/// </summary>
public static void PrintUiTree(AutomationElement element, int indent = 0)
{
string name = element.Current.Name;
string type = element.Current.ControlType.ProgrammaticName;
string automationId = element.Current.AutomationId;
Console.WriteLine(
$"{new string(' ', indent * 2)}{type}: " +
$"Name='{name}', AutomationId='{automationId}'");
// 使用 TreeWalker 遍历子元素
TreeWalker walker = TreeWalker.ControlViewWalker;
AutomationElement child = walker.GetFirstChild(element);
while (child != null)
{
PrintUiTree(child, indent + 1);
child = walker.GetNextSibling(child);
}
}
}使用 Automation Pattern 操作控件
/// <summary>
/// 使用 UI Automation Pattern 执行操作
/// </summary>
public static class AutomationPatternHelper
{
// 点击按钮(InvokePattern)
public static void ClickButton(AutomationElement button)
{
var invokePattern = button.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
invokePattern?.Invoke();
}
// 设置文本框内容(ValuePattern)
public static void SetText(AutomationElement textBox, string text)
{
// 先聚焦
textBox.SetFocus();
var valuePattern = textBox.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern;
if (valuePattern != null)
{
valuePattern.SetValue(text);
}
else
{
// 某些控件不支持 ValuePattern,使用 SendKeys
System.Windows.Forms.SendKeys.SendWait(text);
}
}
// 获取文本框内容
public static string GetText(AutomationElement textBox)
{
var valuePattern = textBox.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern;
if (valuePattern != null)
{
return valuePattern.Current.Value;
}
// 尝试使用 TextPattern
var textPattern = textBox.GetCurrentPattern(TextPattern.Pattern) as TextPattern;
if (textPattern != null)
{
return textPattern.DocumentRange.GetText(-1);
}
return string.Empty;
}
// 选择下拉框项(SelectionPattern + ExpandCollapsePattern)
public static void SelectComboBoxItem(AutomationElement comboBox, string itemName)
{
// 展开下拉框
var expandPattern = comboBox.GetCurrentPattern(ExpandCollapsePattern.Pattern)
as ExpandCollapsePattern;
expandPattern?.Expand();
// 查找目标项
var condition = new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem),
new PropertyCondition(AutomationElement.NameProperty, itemName)
);
AutomationElement item = comboBox.FindFirst(TreeScope.Descendants, condition);
if (item != null)
{
// 选中该项
var selectionItem = item.GetCurrentPattern(SelectionItemPattern.Pattern)
as SelectionItemPattern;
selectionItem?.Select();
}
// 收起下拉框
expandPattern?.Collapse();
}
// 勾选复选框(TogglePattern)
public static void CheckCheckBox(AutomationElement checkBox, bool isChecked)
{
var togglePattern = checkBox.GetCurrentPattern(TogglePattern.Pattern) as TogglePattern;
if (togglePattern != null)
{
bool currentState = togglePattern.Current.ToggleState == ToggleState.On;
if (currentState != isChecked)
{
togglePattern.Toggle();
}
}
}
}FlaUI 使用详解
FlaUI 核心概念
/// <summary>
/// FlaUI 是基于 UI Automation 的现代 WPF/WinForms UI 测试框架
/// 支持 UIA2 (UIAutomationClient) 和 UIA3 (UIAutomation)
/// 推荐使用 UIA3,功能更强大
///
/// NuGet 安装:
/// Install-Package FlaUI.UIA3
/// Install-Package FlaUI.Core
/// </summary>
using FlaUI.UIA3;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using FlaUI.Core.Conditions;
public class FlaUIBasicTests : IDisposable
{
private readonly Application _application;
private readonly UIA3Automation _automation;
private readonly Window _mainWindow;
public FlaUIBasicTests()
{
_automation = new UIA3Automation();
// 启动被测应用
string appPath = @"C:\MyApp\MyWpfApp.exe";
_application = Application.Launch(appPath);
// 获取主窗口
_mainWindow = _application.GetMainWindow(_automation);
Assert.NotNull(_mainWindow);
}
public void Dispose()
{
_mainWindow?.Dispose();
_application?.Close();
_application?.Dispose();
_automation?.Dispose();
}
[Fact]
public void Login_WithValidCredentials_NavigatesToDashboard()
{
// 查找控件(使用 AutomationId 优先)
var usernameBox = _mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("TxtUsername"))
.AsTextBox();
var passwordBox = _mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("TxtPassword"))
.AsTextBox();
var loginButton = _mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("BtnLogin"))
.AsButton();
// 输入凭据
usernameBox.Text = "admin";
passwordBox.Text = "password123";
// 点击登录
loginButton.Invoke();
// 验证:等待 Dashboard 窗口出现
var dashboard = _mainWindow.FindFirstDescendant(cf => cf.ByName("Dashboard"),
timeout: TimeSpan.FromSeconds(5));
Assert.NotNull(dashboard);
}
}高级元素查找
/// <summary>
/// FlaUI 多种元素查找策略
/// </summary>
public class ElementFindingStrategies
{
private readonly UIA3Automation _automation;
private readonly Window _window;
public ElementFindingStrategies(Window window, UIA3Automation automation)
{
_window = window;
_automation = automation;
}
// 1. 通过 AutomationId 查找(最推荐)
public AutomationElement FindByAutomationId(string automationId)
{
return _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
}
// 2. 通过 Name 查找
public AutomationElement FindByName(string name)
{
return _window.FindFirstDescendant(cf => cf.ByName(name));
}
// 3. 通过 ClassName 查找
public AutomationElement FindByClassName(string className)
{
return _window.FindFirstDescendant(cf => cf.ByClassName(className));
}
// 4. 通过 ControlType 查找
public AutomationElement FindByControlType(ControlType controlType)
{
return _window.FindFirstDescendant(cf => cf.ByControlType(controlType));
}
// 5. 组合条件查找
public AutomationElement FindByMultipleConditions(string name, ControlType type)
{
return _window.FindFirstDescendant(cf => cf
.ByControlType(type)
.And(cf.ByName(name)));
}
// 6. 使用 XPath 查找(UIA3 特性)
public AutomationElement FindByXPath(string xpath)
{
return _window.FindFirstByXPath(xpath);
}
// 7. 查找所有匹配元素
public AutomationElement[] FindAllButtons()
{
return _window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button));
}
// 8. 带超时的等待查找
public AutomationElement FindWithRetry(string automationId, TimeSpan timeout)
{
var deadline = DateTime.Now + timeout;
while (DateTime.Now < deadline)
{
var element = _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
if (element != null && element.IsAvailable)
return element;
Thread.Sleep(100);
}
throw new TimeoutException(
$"未能在 {timeout.TotalSeconds} 秒内找到元素: {automationId}");
}
}模拟用户输入
/// <summary>
/// FlaUI 输入模拟
/// </summary>
public class InputSimulation
{
private readonly Window _window;
public InputSimulation(Window window)
{
_window = window;
}
// 键盘输入
public void TypeText(string automationId, string text)
{
var textBox = _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId))
.AsTextBox();
textBox.Focus();
textBox.Text = string.Empty; // 清空
// 方式1:直接设置文本
textBox.Text = text;
// 方式2:逐字符输入(更接近真实用户操作)
foreach (char c in text)
{
Keyboard.Type(c);
}
}
// 快捷键
public void PressShortcut()
{
Keyboard.Pressing(FlaUI.Core.Input.Keyboard.Shortcut(
VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_S));
}
// 鼠标点击
public void ClickElement(string automationId)
{
var element = _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
element?.Click();
}
// 双击
public void DoubleClickElement(string automationId)
{
var element = _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
element?.DoubleClick();
}
// 右键点击
public void RightClickElement(string automationId)
{
var element = _window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
element?.RightClick();
}
// 拖拽操作
public void DragAndDrop(string sourceId, string targetId)
{
var source = _window.FindFirstDescendant(cf => cf.ByAutomationId(sourceId));
var target = _window.FindFirstDescendant(cf => cf.ByAutomationId(targetId));
if (source != null && target != null)
{
Mouse.DragFrom(source.GetCenter(), target.GetCenter());
}
}
}Page Object 模式
封装测试页面
/// <summary>
/// Page Object 模式 — 将页面元素和操作封装为独立类
/// 优点:UI 变化时只需修改 Page 类,不影响测试逻辑
/// </summary>
// 登录页面
public class LoginPage
{
private readonly Window _window;
public LoginPage(Window window)
{
_window = window;
}
// 元素属性
private TextBox UsernameBox =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("TxtUsername")).AsTextBox();
private TextBox PasswordBox =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("TxtPassword")).AsTextBox();
private Button LoginButton =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("BtnLogin")).AsButton();
private Label ErrorMessage =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("LblError")).AsLabel();
// 操作方法
public LoginPage EnterUsername(string username)
{
UsernameBox.Text = username;
return this; // 链式调用
}
public LoginPage EnterPassword(string password)
{
PasswordBox.Text = password;
return this;
}
public DashboardPage ClickLogin()
{
LoginButton.Invoke();
// 等待 Dashboard 加载
var dashboard = _window.FindFirstDescendant(
cf => cf.ByAutomationId("DashboardView"),
timeout: TimeSpan.FromSeconds(5));
Assert.NotNull(dashboard);
return new DashboardPage(_window);
}
public LoginPage ClickLoginExpectingError()
{
LoginButton.Invoke();
return this;
}
// 验证方法
public string GetErrorMessage()
{
return ErrorMessage?.Text ?? string.Empty;
}
public bool IsLoginButtonEnabled => LoginButton.IsEnabled;
}
// Dashboard 页面
public class DashboardPage
{
private readonly Window _window;
public DashboardPage(Window window)
{
_window = window;
}
private TabControl MainTabControl =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("MainTabControl")).AsTabControl();
private DataGrid OrdersGrid =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("OrdersGrid")).AsDataGrid();
public DashboardPage SelectTab(string tabName)
{
var tab = MainTabControl.FindFirstDescendant(cf => cf.ByName(tabName));
tab?.Click();
return this;
}
public int GetOrderCount()
{
return OrdersGrid?.Rows.Length ?? 0;
}
public OrderDetailPage SelectOrder(int rowIndex)
{
var row = OrdersGrid?.Rows.ElementAtOrDefault(rowIndex);
row?.DoubleClick();
return new OrderDetailPage(_window);
}
}
// 订单详情页面
public class OrderDetailPage
{
private readonly Window _window;
public OrderDetailPage(Window window)
{
_window = window;
}
private TextBox OrderIdBox =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("TxtOrderId")).AsTextBox();
private ComboBox StatusCombo =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("CmbStatus")).AsComboBox();
private Button SaveButton =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("BtnSave")).AsButton();
public string OrderId => OrderIdBox.Text;
public OrderDetailPage SetStatus(string status)
{
StatusCombo.Select(status);
return this;
}
public DashboardPage Save()
{
SaveButton.Invoke();
return new DashboardPage(_window);
}
}使用 Page Object 的测试
/// <summary>
/// 基于 Page Object 的完整测试流程
/// </summary>
public class OrderWorkflowTests : IDisposable
{
private Application _app;
private UIA3Automation _automation;
private Window _mainWindow;
[SetUp]
public void Setup()
{
_automation = new UIA3Automation();
_app = Application.Launch(@"C:\MyApp\MyWpfApp.exe");
_mainWindow = _app.GetMainWindow(_automation);
}
[TearDown]
public void TearDown()
{
_mainWindow?.Dispose();
_app?.Close();
_app?.Dispose();
_automation?.Dispose();
}
[Test]
public void FullOrderWorkflow_Login_CreateOrder_ModifyStatus()
{
// 1. 登录
var dashboard = new LoginPage(_mainWindow)
.EnterUsername("admin")
.EnterPassword("password123")
.ClickLogin();
// 2. 导航到订单管理
dashboard.SelectTab("Orders");
// 3. 验证初始状态
int initialCount = dashboard.GetOrderCount();
// 4. 创建新订单(假设有新建按钮)
// var createPage = dashboard.ClickNewOrder();
// ... 填写订单信息并保存
}
[Test]
public void Login_InvalidCredentials_ShowsError()
{
var loginPage = new LoginPage(_mainWindow);
loginPage
.EnterUsername("invalid")
.EnterPassword("wrong")
.ClickLoginExpectingError();
string error = loginPage.GetErrorMessage();
Assert.That(error, Does.Contain("用户名或密码错误"));
}
}数据驱动测试
使用 TestCaseSource 实现数据驱动
/// <summary>
/// 数据驱动的 UI 测试 — 使用多组数据验证同一流程
/// </summary>
public class DataDrivenLoginTests : IDisposable
{
private Application _app;
private UIA3Automation _automation;
private Window _mainWindow;
[SetUp]
public void Setup()
{
_automation = new UIA3Automation();
_app = Application.Launch(@"C:\MyApp\MyWpfApp.exe");
_mainWindow = _app.GetMainWindow(_automation);
}
[TearDown]
public void TearDown()
{
_mainWindow?.Dispose();
_app?.Close();
_app?.Dispose();
_automation?.Dispose();
}
// 测试数据源
public static IEnumerable<TestCaseData> LoginTestCases
{
get
{
yield return new TestCaseData("admin", "admin123", true, "");
yield return new TestCaseData("admin", "wrong", false, "密码错误");
yield return new TestCaseData("", "password", false, "请输入用户名");
yield return new TestCaseData("user", "", false, "请输入密码");
yield return new TestCaseData("disabled", "pass", false, "账号已禁用");
}
}
[Test]
[TestCaseSource(nameof(LoginTestCases))]
public void Login_WithVariousCredentials(
string username, string password,
bool expectSuccess, string expectedError)
{
var loginPage = new LoginPage(_mainWindow);
loginPage.EnterUsername(username);
loginPage.EnterPassword(password);
if (expectSuccess)
{
var dashboard = loginPage.ClickLogin();
Assert.NotNull(dashboard);
}
else
{
loginPage.ClickLoginExpectingError();
Assert.That(loginPage.GetErrorMessage(), Does.Contain(expectedError));
}
}
}JSON 数据文件驱动
/// <summary>
/// 从 JSON 文件加载测试数据
/// </summary>
public class JsonDataDrivenTests
{
private record LoginTestData(
string Username, string Password,
bool ExpectSuccess, string ExpectedMessage);
private List<LoginTestData> LoadTestData(string filePath)
{
string json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<List<LoginTestData>>(json)
?? new List<LoginTestData>();
}
// test_data.json 示例:
// [
// { "Username": "admin", "Password": "admin123", "ExpectSuccess": true, "ExpectedMessage": "" },
// { "Username": "", "Password": "pass", "ExpectSuccess": false, "ExpectedMessage": "请输入用户名" }
// ]
[Test]
public void Login_FromJsonData()
{
var testData = LoadTestData("test_data/login_tests.json");
foreach (var data in testData)
{
using var automation = new UIA3Automation();
using var app = Application.Launch(@"C:\MyApp\MyWpfApp.exe");
var mainWindow = app.GetMainWindow(automation);
try
{
var loginPage = new LoginPage(mainWindow);
loginPage.EnterUsername(data.Username);
loginPage.EnterPassword(data.Password);
if (data.ExpectSuccess)
{
var dashboard = loginPage.ClickLogin();
Assert.NotNull(dashboard, $"登录应成功: {data.Username}");
}
else
{
loginPage.ClickLoginExpectingError();
Assert.That(
loginPage.GetErrorMessage(),
Does.Contain(data.ExpectedMessage),
$"用户 '{data.Username}' 的错误消息不匹配");
}
}
finally
{
mainWindow?.Dispose();
app?.Close();
app?.Dispose();
}
}
}
}截图与可视化验证
自动截图与对比
/// <summary>
/// UI 测试中的截图工具
/// </summary>
public class ScreenshotHelper
{
private readonly string _screenshotDir;
public ScreenshotHelper(string testClassName)
{
_screenshotDir = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Screenshots", testClassName);
Directory.CreateDirectory(_screenshotDir);
}
/// <summary>
/// 捕获窗口截图
/// </summary>
public string CaptureWindow(Window window, string testName)
{
string fileName = $"{testName}_{DateTime.Now:yyyyMMdd_HHmmss}.png";
string filePath = Path.Combine(_screenshotDir, fileName);
// 使用 FlaUI 截图
var screenshot = window.Capture();
screenshot.ToFile(filePath);
return filePath;
}
/// <summary>
/// 捕获控件截图
/// </summary>
public string CaptureElement(AutomationElement element, string elementName)
{
string fileName = $"{elementName}_{DateTime.Now:yyyyMMdd_HHmmss}.png";
string filePath = Path.Combine(_screenshotDir, fileName);
var screenshot = element.Capture();
screenshot.ToFile(filePath);
return filePath;
}
/// <summary>
/// 简单的截图对比(像素差异百分比)
/// </summary>
public double CompareScreenshots(string actualPath, string expectedPath)
{
using var actual = System.Drawing.Image.FromFile(actualPath) as System.Drawing.Bitmap;
using var expected = System.Drawing.Image.FromFile(expectedPath) as System.Drawing.Bitmap;
if (actual == null || expected == null)
return 100.0;
if (actual.Width != expected.Width || actual.Height != expected.Height)
return 100.0;
long totalDiff = 0;
int totalPixels = actual.Width * actual.Height;
for (int x = 0; x < actual.Width; x++)
{
for (int y = 0; y < actual.Height; y++)
{
var p1 = actual.GetPixel(x, y);
var p2 = expected.GetPixel(x, y);
totalDiff += Math.Abs(p1.R - p2.R)
+ Math.Abs(p1.G - p2.G)
+ Math.Abs(p1.B - p2.B);
}
}
// 返回差异百分比(0 = 完全相同,100 = 完全不同)
return (totalDiff / (totalPixels * 3.0 * 255.0)) * 100.0;
}
}CI/CD 集成
在 CI 管道中运行 UI 测试
/// <summary>
/// CI 环境下的 UI 测试配置
/// </summary>
// 1. 测试基类 — 处理 CI 环境的特殊配置
public abstract class UiTestBase : IDisposable
{
protected Application App { get; private set; }
protected UIA3Automation Automation { get; private set; }
protected Window MainWindow { get; private set; }
protected ScreenshotHelper Screenshots { get; private set; }
[SetUp]
public virtual void SetUp()
{
Automation = new UIA3Automation();
Screenshots = new ScreenshotHelper(GetType().Name);
string appPath = GetAppPath();
App = Application.Launch(appPath);
// CI 环境可能需要更长的等待时间
int timeout = IsCIEnvironment() ? 30 : 10;
MainWindow = App.GetMainWindow(Automation, TimeSpan.FromSeconds(timeout));
}
[TearDown]
public virtual void TearDown()
{
// 测试失败时自动截图
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
{
try
{
Screenshots.CaptureWindow(MainWindow,
TestContext.CurrentContext.Test.Name);
}
catch { /* 截图失败不影响测试结果 */ }
}
MainWindow?.Dispose();
App?.Close();
App?.Dispose();
Automation?.Dispose();
}
private string GetAppPath()
{
string baseDir = IsCIEnvironment()
? @"C:\build\output\publish"
: @"C:\MyApp";
return Path.Combine(baseDir, "MyWpfApp.exe");
}
private bool IsCIEnvironment()
{
return Environment.GetEnvironmentVariable("CI") == "true"
|| Environment.GetEnvironmentVariable("TF_BUILD") == "True";
}
public void Dispose()
{
TearDown();
}
}Azure DevOps Pipeline 配置
# azure-pipelines.yml UI 测试阶段
trigger:
branches:
include: [ main, develop ]
stages:
- stage: UITests
dependsOn: Build
jobs:
- job: RunUITests
pool:
vmImage: 'windows-latest'
steps:
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.x'
# UI 测试需要交互式会话
- task: VSTest@2
inputs:
testSelector: 'testAssemblies'
testAssemblyVer2: |
**\*UiTests.dll
searchFolder: '$(System.DefaultWorkingDirectory)'
runInParallel: false
codeCoverageEnabled: true
env:
CI: 'true'
# 发布截图
- task: PublishBuildArtifacts@1
condition: always()
inputs:
pathToPublish: 'Screenshots'
artifactName: 'UiTestScreenshots'无障碍测试
自动化可访问性验证
/// <summary>
/// WPF 无障碍(Accessibility)自动化测试
/// 验证 UI 元素是否满足可访问性要求
/// </summary>
public class AccessibilityTests : UiTestBase
{
[Test]
public void AllInteractiveElements_ShouldHaveAutomationId()
{
// 获取所有可交互元素
var interactiveTypes = new[]
{
ControlType.Button,
ControlType.TextBox,
ControlType.ComboBox,
ControlType.CheckBox,
ControlType.RadioButton,
ControlType.ListBox
};
var missingIds = new List<string>();
foreach (var controlType in interactiveTypes)
{
var elements = MainWindow.FindAllDescendants(
cf => cf.ByControlType(controlType));
foreach (var element in elements)
{
if (string.IsNullOrEmpty(element.AutomationId))
{
missingIds.Add(
$"{controlType}: Name='{element.Name}'");
}
}
}
Assert.IsEmpty(missingIds,
$"以下元素缺少 AutomationId:\n{string.Join("\n", missingIds)}");
}
[Test]
public void AllButtons_ShouldHaveAccessibleName()
{
var buttons = MainWindow.FindAllDescendants(
cf => cf.ByControlType(ControlType.Button));
var unnamedButtons = buttons
.Where(b => string.IsNullOrEmpty(b.Name))
.ToList();
Assert.IsEmpty(unnamedButtons,
$"{unnamedButtons.Count} 个按钮缺少可访问名称");
}
[Test]
public void TextBox_ShouldHaveHelpTextOrLabel()
{
var textBoxes = MainWindow.FindAllDescendants(
cf => cf.ByControlType(ControlType.TextBox));
foreach (var textBox in textBoxes)
{
bool hasHelpText = !string.IsNullOrEmpty(textBox.HelpText);
bool hasLabel = !string.IsNullOrEmpty(textBox.Name);
Assert.IsTrue(hasHelpText || hasLabel,
$"文本框 (AutomationId={textBox.AutomationId}) " +
"缺少 HelpText 或关联 Label");
}
}
}性能测试
UI 响应时间测试
/// <summary>
/// 自动化 UI 性能测试
/// </summary>
public class UiPerformanceTests : UiTestBase
{
[Test]
public void ApplicationStartup_ShouldCompleteWithin3Seconds()
{
var stopwatch = Stopwatch.StartNew();
string appPath = GetAppPath();
var app = Application.Launch(appPath);
var window = app.GetMainWindow(Automation, TimeSpan.FromSeconds(10));
stopwatch.Stop();
Assert.Less(stopwatch.ElapsedMilliseconds, 3000,
$"应用启动耗时 {stopwatch.ElapsedMilliseconds}ms,超过 3 秒阈值");
window?.Dispose();
app?.Close();
app?.Dispose();
}
[Test]
public void DataGridLoading_1000Rows_ShouldCompleteWithin2Seconds()
{
// 导航到数据页面
var dashboard = new LoginPage(MainWindow)
.EnterUsername("admin")
.EnterPassword("password123")
.ClickLogin();
dashboard.SelectTab("Orders");
// 测量 DataGrid 加载时间
var stopwatch = Stopwatch.StartNew();
var dataGrid = MainWindow.FindFirstDescendant(
cf => cf.ByAutomationId("OrdersGrid"),
timeout: TimeSpan.FromSeconds(5));
// 等待所有行加载完成
Wait.Until(() =>
{
var grid = dataGrid.AsDataGrid();
return grid.Rows.Length >= 1000;
}, timeout: TimeSpan.FromSeconds(10));
stopwatch.Stop();
Assert.Less(stopwatch.ElapsedMilliseconds, 2000,
$"DataGrid 加载 1000 行耗时 {stopwatch.ElapsedMilliseconds}ms");
}
[Test]
public void WindowSwitching_ShouldBeResponsive()
{
var measurements = new List<long>();
for (int i = 0; i < 10; i++)
{
var stopwatch = Stopwatch.StartNew();
// 切换 Tab
var tab = MainWindow.FindFirstDescendant(cf => cf.ByName($"Tab{i % 3}"));
tab?.Click();
// 等待内容渲染完成
Wait.UntilResponsive(MainWindow);
stopwatch.Stop();
measurements.Add(stopwatch.ElapsedMilliseconds);
}
double avgTime = measurements.Average();
long maxTime = measurements.Max();
Assert.Less(avgTime, 200, $"平均切换时间 {avgTime:F0}ms 超过 200ms");
Assert.Less(maxTime, 500, $"最大切换时间 {maxTime}ms 超过 500ms");
}
}优点
缺点
性能注意事项
总结
WPF 自动化 UI 测试是质量保障体系的重要组成。FlaUI 提供了现代化的 API 来替代原生 UI Automation 的复杂调用。Page Object 模式有效降低了维护成本。数据驱动测试提高了覆盖率。在 CI/CD 中集成 UI 测试可以实现持续质量监控。关键是在投入产出比之间找到平衡:核心流程必须覆盖,边缘场景酌情处理。
关键知识点
- UI Automation 是 Windows 平台 UI 自动化的基础设施
- FlaUI UIA3 是目前最推荐的 WPF UI 测试框架
- AutomationId 是定位元素的首选属性,每个可交互元素都应设置
- Page Object 模式将页面结构与测试逻辑解耦
- 数据驱动测试可以用同一流程验证多组输入
- CI 环境运行 UI 测试需要交互式桌面会话
- 无障碍测试既是合规要求,也是自动化测试的基础
- UI 性能测试可以量化用户体验指标
常见误区
- 误区:所有测试都应该写成 UI 测试
纠正:UI 测试只应覆盖核心业务流程,大部分逻辑应通过单元测试覆盖 - 误区:UI 测试可以完全替代手动测试
纠正:视觉体验、布局美感等仍需人工判断 - 误区:使用 Thread.Sleep 等待元素出现
纠正:应使用条件等待(轮询+超时),Sleep 导致测试不稳定 - 误区:每个测试用例都从头启动应用
纠正:关联测试可以复用应用实例,减少启动开销 - 误区:UI 测试必须覆盖所有控件的所有状态
纠正:聚焦核心业务流程,避免过度覆盖
进阶路线
- 初级:掌握 FlaUI 基本元素查找和操作,编写简单测试
- 中级:应用 Page Object 模式,数据驱动测试,截图验证
- 高级:CI/CD 集成,无障碍测试,性能测试,并行优化
- 专家级:自定义 FlaUI 插件,AI 辅助测试生成,视觉回归测试平台
适用场景
- 核心业务流程的回归测试(登录、下单、审批等)
- 重构后的功能验证
- 版本升级前的兼容性验证
- 无障碍合规性测试
- UI 性能基准测试
- 持续集成质量门禁
落地建议
- 从核心业务流程开始(登录 + 主要操作),逐步扩展覆盖范围
- 所有 WPF 控件必须设置 AutomationId,作为测试基础设施
- 建立 UiTestBase 基类统一处理应用启动/关闭和异常截图
- UI 测试与单元测试分开项目,独立运行
- CI 中设置失败自动截图并作为构建产物发布
- 定期清理失效的 UI 测试,保持测试套件健康
排错清单
复盘问题
- UI 测试运行时偶尔失败但本地通过,可能原因有哪些?
- 如何在不修改应用代码的情况下提高元素查找效率?
- Page Object 模式中如何处理弹窗和对话框?
- 多窗口应用如何管理和切换测试目标窗口?
- 如何在 UI 测试中验证 MVVM 的数据绑定正确性?
- 测试中遇到 "元素不可点击" 错误,如何排查?
- 如何让 UI 测试在不同分辨率的 CI Agent 上稳定运行?
- FlaUI 的 UIA2 和 UIA3 在实际项目中如何选择?
