windows-desktop-e2e

windows-desktop-e2e

熱門

使用 pywinauto 與 Windows UI Automation 針對 Windows 原生桌面應用程式(WPF、WinForms、Win32/MFC、Qt)進行端到端(E2E)測試。

24萬星標
3.6萬分支
更新於 2026/7/29
SKILL.md
唯讀
名稱
windows-desktop-e2e
描述

使用 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-testing skill (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