Windows 桌面端對端測試
使用 pywinauto(後端為 Windows UI Automation (UIA))對 Windows 原生桌面應用程式進行端對端測試。涵蓋 WPF、WinForms、Win32/MFC 和 Qt(5.x / 6.x)— 其中 Qt 有專門章節提供指引。
何時啟用
- 為 Windows 原生桌面應用程式撰寫或執行 E2E 測試
- 從頭開始建立桌面 GUI 測試套件
- 診斷不穩定或失敗的桌面自動化測試
- 為現有應用程式加入可測試性(AutomationId、易於存取的命名)
- 將桌面 E2E 整合至 CI/CD 管線(GitHub Actions 的
windows-latest)
何時不使用
- 網頁應用程式 → 使用
e2e-testing技能(Playwright) - Electron / CEF / WebView2 應用程式 → HTML 層需要瀏覽器自動化,而非 UIA
- 行動應用程式 → 使用平台專用工具(UIAutomator、XCUITest)
- 不需要執行中 GUI 的純單元測試或整合測試
核心概念
所有 Windows 桌面自動化都依賴 UI Automation (UIA),這是一個 Windows 內建的無障礙 API。每個受支援的框架都會暴露一個 UIA 元素的樹狀結構,Claude 可以讀取並對其進行操作:
您的測試 (Python)
└── pywinauto (UIA 後端)
└── Windows UI Automation API ← 內建於 Windows,與框架無關
└── 應用程式的 UIA 提供者 ← 每個框架都有自己的提供者
└── 正在執行的 .exe
各框架的 UIA 品質:
| 框架 | AutomationId | 可靠性 | 備註 |
|---|---|---|---|
| WPF | 5/5 | 極佳 | x:Name 直接對應到 AutomationId |
| WinForms | 4/5 | 良好 | AccessibleName 作為 AutomationId |
| UWP / WinUI 3 | 5/5 | 極佳 | 完整的 Microsoft 支援 |
| Qt 6.x | 5/5 | 極佳 | 預設啟用無障礙功能;類別名稱變更為 Qt6* |
| Qt 5.15+ | 4/5 | 良好 | 改善的 Accessibility 模組 |
| Qt 5.7–5.14 | 3/5 | 尚可 | 需要 QT_ACCESSIBILITY=1;需手動設定 objectName |
| Win32 / MFC | 3/5 | 尚可 | 可存取控制項 ID;常見使用文字匹配 |
設定與先決條件
# Python 3.8+,僅限 Windows
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# 選用:螢幕錄製
# 安裝 ffmpeg 並加入 PATH:https://ffmpeg.org/download.html
驗證 UIA 是否可存取:
from pywinauto import Desktop
Desktop(backend="uia").windows() # 列出所有頂層視窗
安裝 Accessibility Insights for Windows(免費,來自 Microsoft)— 這是您在撰寫任何測試之前檢查 UIA 元素樹的 DevTools 等效工具。
可測試性設定(依框架)
在撰寫測試之前,能做的最有效一件事就是為每個互動式控制項賦予穩定的 AutomationId。
WPF
<!-- XAML:x:Name 自動變成 AutomationId -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="登入" />
<TextBlock x:Name="lblError" />
WinForms
// 在設計工具或程式碼中設定
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// .rc 檔案中的控制項資源 ID 會以 AutomationId 字串形式暴露
// IDC_EDIT_USERNAME -> AutomationId "1001"
// 建議優先使用 SetWindowText 設定 Name;並加入 IAccessible 以獲得更好的支援
Qt — 請參閱下方專門章節
頁面物件模型
tests/
├── conftest.py # 應用程式啟動 fixture、失敗截圖
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # 匯入所需
│ ├── base_page.py # 定位器、等待、截圖輔助方法
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # 螢幕截圖、影片、日誌
base_page.py
import os, time
from pywinauto import Desktop
from config import ACTION_TIMEOUT, ARTIFACT_DIR
class BasePage:
def __init__(self, window):
self.window = window
# --- 定位器(優先順序)---
def by_id(self, auto_id, **kw):
"""AutomationId — 最穩定。優先使用。"""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""可見文字 / 易於存取的名稱。"""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""控制項類別 + 索引 — 脆弱,請盡量避免。"""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- 等待 ---
def wait_visible(self, spec, timeout=ACTION_TIMEOUT):
spec.wait("visible", timeout=timeout)
return spec
def wait_gone(self, spec, timeout=ACTION_TIMEOUT):
spec.wait_not("visible", timeout=timeout)
return spec
def wait_window(self, title, timeout=ACTION_TIMEOUT):
"""等待新的頂層視窗(對話框、子視窗)。"""
dlg = Desktop(backend="uia").window(title=title)
dlg.wait("visible", timeout=timeout)
return dlg
def wait_until(self, fn, timeout=ACTION_TIMEOUT, interval=0.3):
"""輪詢任意條件 — 在 UIA 事件不可靠時使用。"""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if fn():
return True
except Exception:
pass
time.sleep(interval)
raise TimeoutError(f"條件在 {timeout}s 內未滿足")
# --- 動作 ---
def click(self, spec):
self.wait_visible(spec)
spec.click_input()
def type_text(self, spec, text):
self.wait_visible(spec)
ctrl = spec.wrapper_object()
try:
ctrl.set_edit_text(text)
except Exception as e:
# Qt 5.x 備援:UIA Value Pattern 可能不完整
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text 失敗 ({e}),使用鍵盤備援", file=sys.stderr)
ctrl.click_input()
kb.send_keys("^a")
kb.send_keys(text, with_spaces=True)
def get_text(self, spec):
ctrl = spec.wrapper_object()
for attr in ("window_text", "get_value"):
try:
v = getattr(ctrl, attr)()
if v:
return v
except Exception:
pass
return ""
# --- 工件 ---
def screenshot(self, name):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
path = os.path.join(ARTIFACT_DIR, f"{name}.png")
self.window.capture_as_image().save(path)
return path
login_page.py
from pages.base_page import BasePage
class LoginPage(BasePage):
@property
def username(self): return self.by_id("usernameInput")
@property
def password(self): return self.by_id("passwordInput")
@property
def btn_login(self): return self.by_id("btnLogin")
@property
def error_label(self): return self.by_id("lblError")
def login(self, user, pwd):
self.type_text(self.username, user)
self.type_text(self.password, pwd)
self.click(self.btn_login)
def login_ok(self, user, pwd, main_title="主視窗"):
self.login(user, pwd)
return self.wait_window(main_title)
def login_fail(self, user, pwd):
self.login(user, pwd)
self.wait_visible(self.error_label)
return self.get_text(self.error_label)
conftest.py
對於新專案,建議優先使用第 1 層沙箱 fixture(見下方說明)— 它以零額外成本增加了檔案系統隔離。此基本 fixture 僅適用於最小/舊版設定。
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Qt 5.x 的 UIA 支援所需
from pywinauto import Application
from config import APP_PATH, MAIN_WINDOW_TITLE, LAUNCH_TIMEOUT, ARTIFACT_DIR
@pytest.fixture
def app(request):
if not APP_PATH:
pytest.exit("未設定 APP_PATH 環境變數", returncode=1)
proc = Application(backend="uia").start(APP_PATH, timeout=LAUNCH_TIMEOUT)
win = proc.window(title=MAIN_WINDOW_TITLE)
win.wait("visible", timeout=LAUNCH_TIMEOUT)
yield win
# 失敗時截圖
if getattr(getattr(request.node, "rep_call", None), "failed", False):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
win.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"FAIL_{request.node.name}.png")
)
except Exception:
pass
# 先優雅退出,強制終止作為備援
# proc 是一個 pywinauto Application — 使用 wait_for_process_exit(),而非 wait_for_process()
try:
win.close()
proc.wait_for_process_exit(timeout=5)
except Exception:
proc.kill()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
config.py
import os
APP_PATH = os.environ.get("APP_PATH", "") # 透過 env 設定 — 無預設路徑
MAIN_WINDOW_TITLE = os.environ.get("APP_TITLE", "")
LAUNCH_TIMEOUT = int(os.environ.get("LAUNCH_TIMEOUT", "15"))
ACTION_TIMEOUT = int(os.environ.get("ACTION_TIMEOUT", "10"))
ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
pytest.ini
[pytest]
testpaths = tests
markers =
smoke: 關鍵路徑的快速冒煙測試
flaky: 已知不穩定的測試
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
定位策略
AutomationId > Name (文字) > ClassName + index > XPath
(穩定) (可讀) (脆弱) (最後手段)
使用 Accessibility Insights 檢查 → 屬性窗格 → 先尋找 AutomationId。
# 在執行時期檢查 — 貼到 REPL 以探索樹狀結構
win.print_control_identifiers()
# 或縮小範圍:
win.child_window(auto_id="groupBox1").print_control_identifiers()
等待模式
# 等待控制項出現
page.wait_visible(page.by_id("statusLabel"))
# 等待控制項消失(例如載入中的轉圈動畫)
page.wait_gone(page.by_id("spinnerOverlay"))
# 等待對話框彈出
dlg = page.wait_window("確認刪除")
# 自訂條件(例如文字變更)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "就緒")
切勿使用 time.sleep() 作為主要同步機制 — 請使用 wait() 或 wait_until()。
工件管理
# 即時截圖
page.screenshot("after_login")
# 全螢幕截圖(當視窗在螢幕外或最小化時)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# 使用 ffmpeg 進行螢幕錄製(測試開始前啟動,結束後停止)
import subprocess
def start_recording(name):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{name}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_recording(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
逐步追蹤(選擇性啟用)
預設的失敗截圖通常不足以診斷不穩定的測試。以下步驟層級的追蹤預設為關閉 — 僅在重現不穩定案例時啟用。
啟用
E2E_TRACE=1 pytest tests/test_login.py -v
# 在 JSONL 日誌中包含輸入的文字(請勿用於輸入憑證/PII 的測試):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
打補丁到 BasePage
import os, json, time
TRACE_ENABLED = os.environ.get("E2E_TRACE") == "1"
TRACE_INCLUDE_TEXT = os.environ.get("E2E_TRACE_INCLUDE_TEXT") == "1"
class BasePage:
_step = 0
def _trace(self, action, spec=None, text=None):
if not TRACE_ENABLED:
return
BasePage._step += 1
idx = f"{BasePage._step:03d}"
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
self.window.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"step_{idx}_{action}.png"))
except Exception:
pass # 截圖失敗不得中斷測試
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<redacted>" if text else None),
}
with open(os.path.join(ARTIFACT_DIR, "trace.jsonl"), "a") as f:
f.write(json.dumps(rec) + "\n")
def click(self, spec):
self.wait_visible(spec); self._trace("click_before", spec)
spec.click_input(); self._trace("click_after", spec)
def type_text(self, spec, text):
self.wait_visible(spec); self._trace("type_before", spec, text)
# ... 現有的 set_edit_text / 鍵盤備援 ...
self._trace("type_after", spec)
注意事項
- PII / 憑證:
type_text的內容預設為<redacted>。切勿在登入或付款流程中設定E2E_TRACE_INCLUDE_TEXT=1。 - 開銷:每個動作約需 50~200ms 外加一個 PNG 檔案。請勿在預設的 CI 矩陣中啟用 — 僅在專門處理重現不穩定案例的任務中啟用。
- 工件膨脹:一個長流程可能產生數十 MB;請相應調整
retention-days。 - 並行/重跑衛生:這個簡單範例是附加到
trace.jsonl並使用類別層級的計數器。在重跑之前,請清除工件目錄,並對並行測試使用各工作者的工件目錄。 - 覆蓋缺口:在
BasePage外部執行的動作(測試程式碼中的原始pywinauto呼叫)不會被追蹤。
處理不穩定測試
# 隔離 — 等同於 Playwright 的 test.fixme()
@pytest.mark.skip(reason="不穩定:動畫在慢速 CI 上的競態。Issue #42")
def test_animated_transition(self, app): ...
# 跳過