windows-desktop-e2e

windows-desktop-e2e

热门

基于 pywinauto 与 Windows UI Automation(UIA),实现 Windows 原生桌面应用(WPF、WinForms、Win32/MFC、Qt)的端到端(E2E)自动化测试。

24万Star
3.6万Fork
更新于 2026/7/29
SKILL.md
只读
名称
windows-desktop-e2e
描述

基于 pywinauto 与 Windows UI Automation(UIA),实现 Windows 原生桌面应用(WPF、WinForms、Win32/MFC、Qt)的端到端(E2E)自动化测试。

Windows 桌面端 E2E 测试

基于 pywinauto 与底层 Windows UI Automation (UIA) 框架,实现 Windows 原生桌面应用的端到端(E2E)自动化测试。覆盖 WPF、WinForms、Win32/MFC 以及 Qt (5.x / 6.x) — 其中 Qt 相关的定制化指南见专门章节。

适用场景

  • 编写或运行 Windows 原生桌面应用的 E2E 测试
  • 从零搭建桌面 GUI 自动化测试套件
  • 排查桌面自动化测试中的不稳定(Flaky)或失败用例
  • 为现有应用补充可测试性支持(如添加 AutomationId、Accessible Name 等)
  • 将桌面端 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 内置的无障碍辅助 API。各个受支持的界面框架都会暴露一棵 UIA 元素树,供 Claude 读取属性并执行操作:

你的测试用例 (Python)
    └── pywinauto (UIA backend)
        └── Windows UI Automation API   ← Windows 系统内置,与具体框架无关
            └── 应用的 UIA 提供者      ← 由各 GUI 框架自行实现
                └── 运行中的 .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 可作为 AutomationId 获取;常依赖文本匹配

环境准备与前置要求

# 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

// 在 Designer 或代码中设置
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 模式架构

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):
        """界面可见文本 / 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 ""

    # --- 构件 / 产物管理 ---

    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
    # 优先优雅退出,失败时强制 Kill
    # 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: 核心链路快速冒烟测试
    flaky: 已知不稳定的测试用例
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html

定位策略

AutomationId  >  Name(文本) >  ClassName + 索引  >  XPath
  (最稳定)        (可读性好)       (易失效)         (不得已而为之)

使用 Accessibility Insights 审查工具 → Properties 面板 → 优先寻找 AutomationId

# 运行时查看元素结构 — 可粘贴至 Python REPL 中探索节点树
win.print_control_identifiers()
# 或者缩小排查范围:
win.child_window(auto_id="groupBox1").print_control_identifiers()

等待模式

# 等待控件出现
page.wait_visible(page.by_id("statusLabel"))

# 等待控件消失(如加载 Loading 动画)
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()

构件 / 产物管理

# 按需手动截图
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)

单步 Trace 链路追踪 (Opt-in)

在排查 Flaky 测试时,仅靠失败瞬间的单张截图往往信息量不够。下方的单步 Trace 追踪默认关闭 — 仅在复现不稳定测试用例时手动开启。

开启方式

E2E_TRACE=1 pytest tests/test_login.py -v
# 在 JSONL 日志中包含输入的文本(切勿在涉及密码/敏感数据的测试中开启):
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

<!-- truncated for translation batch; full body continues in source -->