跳至主要内容

最佳實踐


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}ButtonStartButton, SaveButton
TextBox{Field}InputTemperatureInput, IpAddressInput
ComboBox{Field}ComboDeviceTypeCombo
Toggle{Feature}ToggleAutoReconnectToggle
Label{Data}LabelConnectionStatusLabel
DataGrid{Data}GridDeviceDataGrid
Tab{Name}TabMonitorTab, 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.DelayThread.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 不同,使用 IDisposableIAsyncLifetime

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 搜尋

延伸閱讀