使用 pywinauto 與 Windows UI Automation 針對 Windows 原生桌面應用程式(WPF、WinForms、Win32/MFC、Qt)進行端到端(E2E)測試。
Windows 桌面端到端 (E2E) 測試
使用以 Windows UI Automation (UIA) 為後端的 pywinauto,對 Windows 原生桌面應用程式進行端到端測試。支援 WPF、WinForms、Win32/MFC 與 Qt (5.x / 6.x)——其中 Qt 相關的專屬指引獨立收錄於後續章節。
啟用時機
- 撰寫或執行 Windows 原生桌面應用程式的 E2E 測試
- 從零開始建置桌面 GUI 測試套件
- 排查不穩定(flaky)或失敗的桌面自動化測試
- 為現有應用程式新增可測試性(AutomationId、可存取名稱 / accessible names)
- 將桌面 E2E 整合至 CI/CD 流水線(GitHub Actions
windows-latest)
何時不適用
- Web 應用程式 → 請使用
e2e-testingskill (Playwright) - Electron / CEF / WebView2 應用程式 → HTML 層需要瀏覽器自動化,而非 UIA
- 行動端 App → 請使用平台專屬工具(UIAutomator、XCUITest)
- 不需要運行 GUI 的純單元測試或整合測試
核心概念
所有 Windows 桌面自動化皆依賴 UI Automation (UIA),這是 Windows 內建的可存取性(Accessibility)API。所有受支援的框架都會暴露出一棵 UIA 元素樹,供 Claude 讀取屬性並進行操作:
你的測試 (Python)
└── pywinauto (UIA backend)
└── Windows UI Automation API ← Windows 內建,與框架無關
└── 應用程式的 UIA 提供者 (provider) ← 各框架內建
└── 運行中的 .exe
各框架的 UIA 品質比較:
| 框架 | AutomationId | 穩定度 | 說明 |
|---|---|---|---|
| WPF | 5/5 | 極佳 | x:Name 會直接映射至 AutomationId |
| WinForms | 4/5 | 良好 | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | 極佳 | 微軟官方完整支援 |
| Qt 6.x | 5/5 | 極佳 | 預設啟用 Accessibility;類別名稱變更為 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(微軟提供的免費工具)——在撰寫任何測試之前,它是用來檢視 UIA 元素樹的開發者工具(DevTools)對應品。
可測試性設定(依框架)
在撰寫測試之前,最具效益的操作就是為每個互動式控制項設定穩定的 AutomationId。
WPF
<!-- XAML:x:Name 會自動轉換為 AutomationId -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Login" />
<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 — 請參閱下方的專屬章節
頁面物件模式 (Page Object Model)
tests/
├── conftest.py # 應用程式啟動 fixture、失敗截圖
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # import 所需
│ ├── 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):
"""可見文字 / 存取名稱 (accessible name)。"""
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"Condition not met within {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 failed ({e}), using keyboard fallback", 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 ""
# --- 產出物 (Artifacts) ---
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="Main Window"):
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
對於新專案,建議優先使用 Tier 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 environment variable is not set", 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", "") # 經由環境變數設定 — 無預設路徑
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: 針對關鍵路徑的快速冒煙測試 (smoke tests)
flaky: 已知不穩定的測試
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
定位器策略 (Locator Strategy)
AutomationId > Name (文字) > ClassName + 索引 > XPath
(穩定) (可讀) (脆弱) (最後手段)
使用 Accessibility Insights 進行檢視 → Properties 面板 → 優先尋找 AutomationId。
# 在執行階段檢視 — 貼至 REPL 以探索樹狀結構
win.print_control_identifiers()
# 或縮小範圍:
win.child_window(auto_id="groupBox1").print_control_identifiers()
等待模式 (Wait Patterns)
# 等待控制項出現
page.wait_visible(page.by_id("statusLabel"))
# 等待控制項消失(例如載入中圖示)
page.wait_gone(page.by_id("spinnerOverlay"))
# 等待對話方塊跳出
dlg = page.wait_window("Confirm Delete")
# 自訂條件(例如文字變更)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
切勿使用 time.sleep() 作為主要同步手段 — 請使用 wait() 或 wait_until()。
產出物管理 (Artifact Management)
# 依需求截圖
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)
單步追蹤 (Per-Step Trace,選用)
預設的失敗截圖通常不足以排查不穩定的測試。以下的單步追蹤功能預設為關閉 — 僅在重現不穩定測試案例時開啟。
開啟方式
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.en






