自動化 Pattern
本頁收集 FlaUI 自動化測試中最常用的 Pattern。
Pattern 1:Page Object Model
將每個畫面封裝成一個類別,讓測試程式碼更容易維護。UI 變更時只需修改 Page Object,不影響測試邏輯。
結構
Tests/
├── PageObjects/
│ ├── MainPage.cs
│ ├── DeviceSettingsPage.cs
│ ├── RecipeEditorPage.cs
│ └── MonitorDashboardPage.cs
├── DeviceConnectionTests.cs
├── RecipeTests.cs
└── TestBase.cs
Page Object 範例
public class MainPage
{
private readonly Window _window;
public MainPage(Window window)
{
_window = window;
}
// 元素定位集中管理
private AutomationElement ConnectButton =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("ConnectButton"));
private AutomationElement StatusLabel =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("ConnectionStatus"));
private AutomationElement IpInput =>
_window.FindFirstDescendant(cf => cf.ByAutomationId("DeviceIpInput"));
// 操作封裝
public void SetDeviceIp(string ip)
{
IpInput.AsTextBox().Text = ip;
}
public void ClickConnect()
{
ConnectButton.AsButton().Invoke();
}
public string GetConnectionStatus()
{
return StatusLabel.AsLabel().Text;
}
// 複合操作
public void ConnectToDevice(string ip)
{
SetDeviceIp(ip);
ClickConnect();
WaitForConnection();
}
public void WaitForConnection(int timeoutSeconds = 10)
{
Retry.WhileException(() =>
{
var status = GetConnectionStatus();
if (status != "已連線")
throw new Exception($"等待連線中,目前狀態: {status}");
}, TimeSpan.FromSeconds(timeoutSeconds));
}
// 導航
public DeviceSettingsPage OpenSettings()
{
_window.FindFirstDescendant(cf =>
cf.ByAutomationId("SettingsButton")).AsButton().Invoke();
Thread.Sleep(500); // 等待 Flyout 動畫
return new DeviceSettingsPage(_window);
}
}
在測試中使用
[Test]
public void ConnectToDevice_ShouldShowConnectedStatus()
{
var mainPage = new MainPage(_mainWindow);
mainPage.ConnectToDevice("192.168.1.100");
Assert.AreEqual("已連線", mainPage.GetConnectionStatus());
}
[Test]
public void ChangeSettings_ShouldSaveSuccessfully()
{
var mainPage = new MainPage(_mainWindow);
var settings = mainPage.OpenSettings();
settings.SetPort(502);
settings.SetAutoReconnect(true);
settings.Save();
// 驗證設定已儲存
var reopened = mainPage.OpenSettings();
Assert.AreEqual(502, reopened.GetPort());
Assert.IsTrue(reopened.GetAutoReconnect());
}
Pattern 2:等待策略
UI 操作是非同步的——點擊按鈕後,結果不會立即出現。FlaUI 提供 Retry 工具類來處理等待。
Retry.WhileException
重複執行直到不拋例外(或逾時):
// 等待元素出現
Retry.WhileException(() =>
{
var element = mainWindow.FindFirstDescendant(cf =>
cf.ByAutomationId("ResultLabel"));
Assert.IsNotNull(element, "元素尚未出現");
}, TimeSpan.FromSeconds(10));
Retry.WhileNull
等待直到回傳非 null:
// 等待 Dialog 彈出
var dialog = Retry.WhileNull(() =>
mainWindow.FindFirstDescendant(cf =>
cf.ByClassName("#32770")), // Windows Dialog 類別名稱
TimeSpan.FromSeconds(5)).Result;
Retry.WhileTrue / WhileFalse
// 等待按鈕變為可用
Retry.WhileTrue(() =>
!startButton.IsEnabled,
TimeSpan.FromSeconds(10));
自訂等待工具
public static class WaitHelper
{
public static void WaitForText(
AutomationElement element, string expectedText,
TimeSpan? timeout = null)
{
timeout ??= TimeSpan.FromSeconds(10);
Retry.WhileException(() =>
{
var actual = element.AsLabel().Text;
if (actual != expectedText)
throw new AssertionException(
$"預期 '{expectedText}',實際 '{actual}'");
}, timeout.Value);
}
public static AutomationElement WaitForElement(
AutomationElement parent, string automationId,
TimeSpan? timeout = null)
{
timeout ??= TimeSpan.FromSeconds(10);
return Retry.WhileNull(() =>
parent.FindFirstDescendant(cf =>
cf.ByAutomationId(automationId)),
timeout.Value).Result;
}
}
Pattern 3:DataGrid 操作
讀取列資料
var dataGrid = mainWindow.FindFirstDescendant(cf =>
cf.ByAutomationId("DeviceDataGrid")).AsDataGridView();
// 讀取所有列
var rows = dataGrid.Rows;
foreach (var row in rows)
{
var name = row.Cells[0].Value;
var value = row.Cells[1].Value;
Console.WriteLine($"{name}: {value}");
}
// 讀取特定列
var firstRow = dataGrid.Rows[0];
Assert.AreEqual("溫度", firstRow.Cells[0].Value);
選取列
// 點擊選取第 3 列
dataGrid.Rows[2].Click();
// 選取後驗證
Assert.IsTrue(dataGrid.Rows[2].IsSelected);
搜尋特定資料
public static DataGridViewRow FindRowByColumnValue(
DataGridView grid, int columnIndex, string value)
{
return grid.Rows.FirstOrDefault(row =>
row.Cells[columnIndex].Value == value);
}
// 使用
var targetRow = FindRowByColumnValue(dataGrid, 0, "壓力感測器");
Assert.IsNotNull(targetRow, "找不到壓力感測器的資料列");
滾動到指定列
// 如果 DataGrid 有虛擬化,可能需要滾動才能找到元素
var scrollPattern = dataGrid.Patterns.Scroll.Pattern;
if (scrollPattern != null)
{
scrollPattern.ScrollVertical(ScrollAmount.LargeIncrement);
}
Pattern 4:Dialog 處理
偵測並處理彈出 Dialog
public static void HandleConfirmDialog(Window parentWindow)
{
// 等待 Dialog 出現
var dialog = Retry.WhileNull(() =>
parentWindow.ModalWindows.FirstOrDefault(),
TimeSpan.FromSeconds(5)).Result;
if (dialog == null) return;
// 找到確定按鈕並點擊
var okButton = dialog.FindFirstDescendant(cf =>
cf.ByName("確定"))?.AsButton()
?? dialog.FindFirstDescendant(cf =>
cf.ByName("OK"))?.AsButton();
okButton?.Invoke();
}
MahApps MetroDialog 處理
MahApps 的 MetroDialog 不是獨立的 Window,而是 Overlay。需要在主視窗中搜尋:
public static void HandleMetroDialog(Window mainWindow, bool confirm = true)
{
// MetroDialog 通常有特定的 AutomationId 或 ClassName
var dialogOverlay = Retry.WhileNull(() =>
mainWindow.FindFirstDescendant(cf =>
cf.ByClassName("BaseMetroDialog")),
TimeSpan.FromSeconds(5)).Result;
if (dialogOverlay == null) return;
var buttonName = confirm ? "AFFIRMATIVE" : "NEGATIVE";
var button = dialogOverlay.FindFirstDescendant(cf =>
cf.ByAutomationId(buttonName))?.AsButton();
button?.Invoke();
}
Pattern 5:截圖
基本截圖
using FlaUI.Core.Capturing;
// 截取整個螢幕
var screenCapture = Capture.Screen();
screenCapture.ToFile("full-screen.png");
// 截取特定視窗
var windowCapture = Capture.Element(mainWindow);
windowCapture.ToFile("main-window.png");
// 截取特定元素
var elementCapture = Capture.Element(dataGrid);
elementCapture.ToFile("data-grid.png");
測試失敗時自動截圖
[TearDown]
public void TearDown()
{
if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
{
var testName = TestContext.CurrentContext.Test.Name;
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var fileName = $"FAIL_{testName}_{timestamp}.png";
var filePath = Path.Combine(TestContext.CurrentContext.WorkDirectory, fileName);
try
{
Capture.Element(_mainWindow).ToFile(filePath);
TestContext.AddTestAttachment(filePath);
}
catch (Exception ex)
{
Console.WriteLine($"截圖失敗: {ex.Message}");
}
}
}
Pattern 6:錄影整合
搭配 FFmpeg 錄製整個測試過程,方便除錯。
public class ScreenRecorder : IDisposable
{
private Process _ffmpeg;
private readonly string _outputPath;
public ScreenRecorder(string outputPath)
{
_outputPath = outputPath;
}
public void Start()
{
_ffmpeg = Process.Start(new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = $"-f gdigrab -framerate 15 -i desktop " +
$"-c:v libx264 -preset ultrafast \"{_outputPath}\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardInput = true
});
}
public void Dispose()
{
if (_ffmpeg != null && !_ffmpeg.HasExited)
{
_ffmpeg.StandardInput.Write("q"); // 送 'q' 給 ffmpeg 停止
_ffmpeg.WaitForExit(5000);
}
}
}
公司場景範例
WPF 設備控制程式的端到端測試
[Test]
public void EndToEnd_ConnectAndReadDevice()
{
var mainPage = new MainPage(_mainWindow);
// 1. 連線到設備
mainPage.ConnectToDevice("192.168.1.100");
Assert.AreEqual("已連線", mainPage.GetConnectionStatus());
// 2. 切換到監控頁面
var monitorPage = mainPage.NavigateToMonitor();
// 3. 等待資料出現
WaitHelper.WaitForElement(
_mainWindow, "TemperatureValue", TimeSpan.FromSeconds(15));
// 4. 驗證資料合理
var temp = monitorPage.GetTemperature();
Assert.That(temp, Is.InRange(0, 200),
$"溫度值不合理: {temp}°C");
}
Recipe 建立/執行的自動化驗收
[Test]
public void Recipe_CreateAndExecute()
{
var mainPage = new MainPage(_mainWindow);
var recipePage = mainPage.NavigateToRecipe();
// 建立新 Recipe
recipePage.ClickNew();
recipePage.SetName("TEST_RECIPE_001");
recipePage.AddStep("加熱", 80, "°C", 60); // 加熱到 80°C,60 秒
recipePage.AddStep("保持", 80, "°C", 300); // 保持 300 秒
recipePage.AddStep("冷卻", 25, "°C", 120); // 冷卻到 25°C
recipePage.Save();
// 執行 Recipe
recipePage.SelectRecipe("TEST_RECIPE_001");
recipePage.ClickRun();
// 驗證執行狀態
WaitHelper.WaitForText(
recipePage.StatusElement, "執行中", TimeSpan.FromSeconds(10));
}
下一步:最佳實踐 — AutomationId、等待策略、CI 環境設定