メインコンテンツまでスキップ

自動化 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 環境設定