最佳實踐
1. 永遠用 AutomationId,不要用 Name
Name 是控件的顯示文字,會隨語系切換而改變。AutomationId 是開發時設定的唯一識別碼,不受語系影響。
在 WPF 中設定 AutomationId
<!-- ✅ 設定 AutomationId -->
<Button x:Name="StartButton"
AutomationProperties.AutomationId="StartButton"
Content="啟動" />
<TextBox AutomationProperties.AutomationId="TemperatureInput" />
<mah:ToggleSwitch AutomationProperties.AutomationId="AutoReconnectToggle" />
備考
WPF 的 x:Name 通常會自動成為 AutomationId,但顯式設定 AutomationProperties.AutomationId 更明確。
搜尋策略
// ✅ 好:用 AutomationId
var button = window.FindFirstDescendant(cf => cf.ByAutomationId("StartButton"));
// ❌ 差:用 Name,多語系會壞
var button = window.FindFirstDescendant(cf => cf.ByName("啟動"));
// ⚠️ 可接受:用 ClassName(某些控件沒有 AutomationId 時的退路)
var dialog = window.FindFirstDescendant(cf => cf.ByClassName("BaseMetroDialog"));
命名慣例建議
| 控件類型 | AutomationId 格式 | 範例 |
|---|---|---|
| Button | {Action}Button | StartButton, SaveButton |
| TextBox | {Field}Input | TemperatureInput, IpAddressInput |
| ComboBox | {Field}Combo | DeviceTypeCombo |
| Toggle | {Feature}Toggle | AutoReconnectToggle |
| Label | {Data}Label | ConnectionStatusLabel |
| DataGrid | {Data}Grid | DeviceDataGrid |
| Tab | {Name}Tab | MonitorTab, SettingsTab |
2. 不要用 Thread.Sleep,用 Retry/WaitUntil
Thread.Sleep 是脆弱的:太短會失敗,太長會浪費時間。
// ❌ 差:固定等待
button.Invoke();
Thread.Sleep(3000); // 可能太短或太長
var result = label.Text;
// ✅ 好:條件等待
button.Invoke();
Retry.WhileException(() =>
{
Assert.AreEqual("完成", label.AsLabel().Text);
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(200));
唯一例外:UI 動畫
某些 UI 動畫(如 MahApps Flyout 滑出)需要短暫等待。這時用 Task.Delay 或 Thread.Sleep 是合理的,但應該限制在 300-800ms:
flyoutButton.Invoke();
Thread.Sleep(500); // 等待 Flyout 動畫完成,這是可接受的
var flyoutContent = window.FindFirstDescendant(cf =>
cf.ByAutomationId("SettingsFlyout"));
3. 測試隔離
每個測試獨立啟動/關閉應用程式
[TestFixture]
public class DeviceControlTests
{
private Application _app;
private Window _mainWindow;
private UIA3Automation _automation;
[SetUp]
public void SetUp()
{
_automation = new UIA3Automation();
_app = Application.Launch("DeviceControl.exe");
_mainWindow = _app.GetMainWindow(_automation, TimeSpan.FromSeconds(15));
}
[TearDown]
public void TearDown()
{
// 截圖(如果失敗)
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
{
CaptureScreenshot();
}
_app?.Close();
// 確保程式完全關閉
if (_app != null && !_app.HasExited)
{
_app.Kill();
}
_automation?.Dispose();
}
private void CaptureScreenshot()
{
try
{
var name = TestContext.CurrentContext.Test.Name;
var path = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
$"FAIL_{name}_{DateTime.Now:HHmmss}.png");
Capture.Element(_mainWindow).ToFile(path);
TestContext.AddTestAttachment(path);
}
catch { /* 截圖失敗不應阻止 TearDown */ }
}
}
使用 TestBase 共享設定
public abstract class UITestBase
{
protected Application App { get; private set; }
protected Window MainWindow { get; private set; }
protected UIA3Automation Automation { get; private set; }
protected virtual string AppPath => @"C:\Program Files\DeviceControl\DeviceControl.exe";
protected virtual string AppArguments => "";
protected virtual int StartupTimeoutSeconds => 15;
[SetUp]
public void BaseSetUp()
{
Automation = new UIA3Automation();
var processInfo = new ProcessStartInfo(AppPath, AppArguments);
App = Application.Launch(processInfo);
MainWindow = App.GetMainWindow(Automation,
TimeSpan.FromSeconds(StartupTimeoutSeconds));
}
[TearDown]
public void BaseTearDown()
{
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
CaptureFailureScreenshot();
App?.Close();
if (App != null && !App.HasExited)
App.Kill();
Automation?.Dispose();
}
private void CaptureFailureScreenshot() { /* ... */ }
}
// 測試類別繼承
public class RecipeTests : UITestBase
{
protected override string AppArguments => "--test-mode";
[Test]
public void CreateRecipe_ShouldSucceed()
{
var recipePage = new RecipePage(MainWindow);
// ...
}
}
4. CI 環境注意事項
FlaUI 需要 Desktop Session(桌面環境)才能運作。沒有桌面的 CI 環境(如 Docker Linux container)無法使用。
Windows CI Agent 設定
| 環境 | 是否支援 | 注意事項 |
|---|---|---|
| Windows + Interactive Session | 支援 | Agent 需以互動式登入執行 |
| Windows + Service Mode | 不支援 | 沒有桌面 Session |
| GitHub Actions (Windows) | 支援 | 預設有桌面 |
| Azure DevOps (Hosted) | 支援 | Windows hosted agent 有桌面 |
| Docker (Windows) | 不支援 | 容器內沒有桌面 |
GitHub Actions 範例
name: UI Tests
on: [push]
jobs:
ui-tests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: dotnet build -c Release
- name: Run UI Tests
run: dotnet test --filter "Category=UITest" -c Release
timeout-minutes: 15
- name: Upload Screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: ui-test-screenshots
path: '**/FAIL_*.png'
解析度設定
CI 環境的螢幕解析度可能與開發環境不同,導致元素位置偏移:
[SetUp]
public void EnsureResolution()
{
// 確保視窗大小一致
MainWindow.Patterns.Transform.Pattern?.Resize(1280, 800);
MainWindow.Patterns.Transform.Pattern?.Move(0, 0);
}
5. 與 xUnit 整合
xUnit 的生命週期與 NUnit 不同,使用 IDisposable 和 IAsyncLifetime:
public class DeviceControlTests : IDisposable
{
private readonly Application _app;
private readonly UIA3Automation _automation;
private readonly Window _mainWindow;
public DeviceControlTests()
{
_automation = new UIA3Automation();
_app = Application.Launch("DeviceControl.exe");
_mainWindow = _app.GetMainWindow(_automation, TimeSpan.FromSeconds(15));
}
[Fact]
public void ConnectButton_ShouldConnect()
{
var mainPage = new MainPage(_mainWindow);
mainPage.ConnectToDevice("192.168.1.100");
Assert.Equal("已連線", mainPage.GetConnectionStatus());
}
public void Dispose()
{
_app?.Close();
if (_app != null && !_app.HasExited)
_app.Kill();
_automation?.Dispose();
}
}
6. 多語系 UI 驗證
利用 FlaUI 驗證所有語系的 UI 是否正確渲染:
[TestCase("zh-TW")]
[TestCase("en-US")]
[TestCase("ja-JP")]
public void AllLanguages_ShouldRenderCorrectly(string culture)
{
// 啟動應用程式並切換語系
var app = Application.Launch("DeviceControl.exe", $"--culture {culture}");
var window = app.GetMainWindow(_automation, TimeSpan.FromSeconds(15));
// 用 AutomationId 找到元素(不受語系影響)
var startButton = window.FindFirstDescendant(cf =>
cf.ByAutomationId("StartButton"));
Assert.IsNotNull(startButton, $"語系 {culture}: StartButton 不存在");
// 截圖留證
Capture.Element(window).ToFile($"UI_{culture}.png");
app.Close();
}
常見問題
| 問題 | 原因 | 解法 |
|---|---|---|
| 找不到元素 | AutomationId 未設定 | 在 XAML 加 AutomationProperties.AutomationId |
| 找不到元素 | 元素在虛擬化的 DataGrid 外 | 先滾動到可見範圍 |
| 點擊無效 | 元素被其他元素遮擋 | 用 Invoke() 代替 Click() |
| 測試不穩定 | 等待時間不夠 | 用 Retry.WhileException 取代 Thread.Sleep |
| CI 上失敗 | 沒有桌面 Session | 確保 CI agent 以互動模式運行 |
| 速度慢 | 搜尋範圍太大 | 縮小搜尋範圍(先找到容器再找子元素) |
| MahApps Dialog 找不到 | 不是獨立視窗 | 在主視窗中用 ClassName 搜尋 |
延伸閱讀:
- 概觀 — FlaUI 核心概念與安裝
- 自動化 Pattern — Page Object、等待策略、截圖
- MahApps 控件 — 被測試的 UI 控件