Windows 桌面端到端(E2E)测试
使用 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)
何时不使用
- Web 应用程序 → 使用
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 | 优秀 | 微软完全支持 |
| Qt 6.x | 5/5 | 优秀 | 默认启用辅助功能;类名变为 Qt6* |
| Qt 5.15+ | 4/5 | 良好 | 辅助功能模块改进 |
| 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 —— 见下方专门章节
页面对象模型
tests/
├── conftest.py # 应用程序启动夹具,失败截图
├── 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="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 沙箱夹具(见下文)——它可以零成本添加文件系统隔离。此基本夹具仅用于最小/传统设置。
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", "") # 通过环境变量设置 —— 无默认路径
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 检查 → 属性 面板 → 首先查找 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("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)
逐步骤跟踪(可选启用)
默认的失败截图对于诊断不稳定测试来说往往信息不足。下面的步骤级跟踪默认关闭——仅在复现不稳定案例时启用。
启用
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): ...
# 跳过 i
…
[已截断以进行翻译]