windows-desktop-e2e
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWindows Desktop E2E Testing
Windows桌面端E2E测试
End-to-end testing for Windows native desktop applications using pywinauto backed by Windows UI Automation (UIA). Covers WPF, WinForms, Win32/MFC, and Qt (5.x / 6.x) — with Qt-specific guidance as a dedicated section.
使用pywinauto结合Windows UI Automation(UIA)对Windows原生桌面应用进行端到端测试。覆盖WPF、WinForms、Win32/MFC以及Qt(5.x / 6.x)——其中Qt相关指南作为独立章节。
When to Activate
适用场景
- Writing or running E2E tests for a Windows native desktop application
- Setting up a desktop GUI test suite from scratch
- Diagnosing flaky or failing desktop automation tests
- Adding testability (AutomationId, accessible names) to an existing app
- Integrating desktop E2E into a CI/CD pipeline (GitHub Actions )
windows-latest
- 为Windows原生桌面应用编写或运行E2E测试
- 从零开始搭建桌面GUI测试套件
- 诊断不稳定或失败的桌面自动化测试
- 为现有应用添加可测试性(AutomationId、可访问名称)
- 将桌面E2E测试集成到CI/CD流水线(GitHub Actions )
windows-latest
When NOT to Use
不适用场景
- Web applications → use skill (Playwright)
e2e-testing - Electron / CEF / WebView2 apps → the HTML layer needs browser automation, not UIA
- Mobile apps → use platform-specific tools (UIAutomator, XCUITest)
- Pure unit or integration tests that don't need a running GUI
- Web应用 → 使用技能(Playwright)
e2e-testing - Electron / CEF / WebView2应用 → HTML层需要浏览器自动化,而非UIA
- 移动应用 → 使用平台专属工具(UIAutomator、XCUITest)
- 无需运行GUI的纯单元测试或集成测试
Core Concepts
核心概念
All Windows desktop automation relies on UI Automation (UIA), a Windows-built-in accessibility API. Every supported framework exposes a tree of UIA elements with properties Claude can read and act on:
Your test (Python)
└── pywinauto (UIA backend)
└── Windows UI Automation API ← built into Windows, framework-agnostic
└── App's UIA provider ← each framework ships its own
└── Running .exeUIA quality by framework:
| Framework | AutomationId | Reliability | Notes |
|---|---|---|---|
| WPF | ★★★★★ | Excellent | |
| WinForms | ★★★★☆ | Good | |
| UWP / WinUI 3 | ★★★★★ | Excellent | Full Microsoft support |
| Qt 6.x | ★★★★★ | Excellent | Accessibility enabled by default; class names change to |
| Qt 5.15+ | ★★★★☆ | Good | Improved Accessibility module |
| Qt 5.7–5.14 | ★★★☆☆ | Fair | Needs |
| Win32 / MFC | ★★★☆☆ | Fair | Control IDs accessible; text matching common |
所有Windows桌面自动化都依赖UI Automation (UIA),这是Windows内置的无障碍API。每个受支持的框架都会暴露UIA元素树,Claude可以读取这些元素的属性并与之交互:
你的测试(Python)
└── pywinauto(UIA后端)
└── Windows UI Automation API ← 内置在Windows中,与框架无关
└── 应用的UIA提供程序 ← 每个框架都自带专属实现
└── 运行中的.exe各框架的UIA质量:
| 框架 | AutomationId | 可靠性 | 说明 |
|---|---|---|---|
| WPF | ★★★★★ | 优秀 | |
| WinForms | ★★★★☆ | 良好 | |
| UWP / WinUI 3 | ★★★★★ | 优秀 | 微软官方全面支持 |
| Qt 6.x | ★★★★★ | 优秀 | 默认启用无障碍功能;类名变为 |
| Qt 5.15+ | ★★★★☆ | 良好 | 无障碍模块已改进 |
| Qt 5.7–5.14 | ★★★☆☆ | 一般 | 需要设置 |
| Win32 / MFC | ★★★☆☆ | 一般 | 可访问控件ID;常用文本匹配 |
Setup & Prerequisites
设置与前置条件
bash
undefinedbash
undefinedPython 3.8+, Windows only
仅支持Python 3.8+、Windows系统
pip install pywinauto pytest pytest-html Pillow pytest-timeout
pip install pywinauto pytest pytest-html Pillow pytest-timeout
Optional: screen recording
可选:屏幕录制
Install ffmpeg and add to PATH: https://ffmpeg.org/download.html
安装ffmpeg并添加到PATH:https://ffmpeg.org/download.html
Verify UIA is reachable:
```python
from pywinauto import Desktop
Desktop(backend="uia").windows() # lists all top-level windowsInstall Accessibility Insights for Windows (free, from Microsoft) — your DevTools equivalent for inspecting the UIA element tree before writing any test.
验证UIA是否可访问:
```python
from pywinauto import Desktop
Desktop(backend="uia").windows() # 列出所有顶级窗口安装Accessibility Insights for Windows(微软提供的免费工具)——这是编写测试前检查UIA元素树的等效DevTools工具。
Testability Setup (by Framework)
按框架配置可测试性
The single most impactful thing you can do is give every interactive control a stable AutomationId before writing tests.
编写测试前,最有效的措施是为每个交互控件设置稳定的AutomationId。
WPF
WPF
xml
<!-- XAML: x:Name becomes AutomationId automatically -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Login" />
<TextBlock x:Name="lblError" />xml
<!-- XAML:x:Name会自动变为AutomationId -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Login" />
<TextBlock x:Name="lblError" />WinForms
WinForms
csharp
// Set in designer or code
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";csharp
// 在设计器或代码中设置
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";Win32 / MFC
Win32 / MFC
cpp
// Control resource IDs in .rc file are exposed as AutomationId strings
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Prefer SetWindowText for Name; add IAccessible for richer supportcpp
// .rc文件中的控件资源ID会暴露为AutomationId字符串
// IDC_EDIT_USERNAME → AutomationId "1001"
// 优先使用SetWindowText设置名称;添加IAccessible以获得更丰富的支持Qt — see dedicated section below
Qt — 请查看下方的独立章节
Page Object Model
页面对象模型
tests/
├── conftest.py # app launch fixture, failure screenshot
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # required for imports
│ ├── base_page.py # locators, wait, screenshot helpers
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # screenshots, videos, logstests/
├── 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
base_page.py
python
import os, time
from pywinauto import Desktop
from config import ACTION_TIMEOUT, ARTIFACT_DIR
class BasePage:
def __init__(self, window):
self.window = window
# --- Locators (priority order) ---
def by_id(self, auto_id, **kw):
"""AutomationId — most stable. Use as first choice."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Visible text / accessible name."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Control class + index — fragile, avoid if possible."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Waits ---
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):
"""Wait for a new top-level window (dialogs, child windows)."""
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):
"""Poll an arbitrary condition — use when UIA events are unreliable."""
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")
# --- Actions ---
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 fallback: UIA Value Pattern may be incomplete
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 pathpython
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}秒内未满足条件")
# --- 操作方法 ---
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值模式可能不完整
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 pathlogin_page.py
login_page.py
python
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)python
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
conftest.py
For new projects prefer the Tier 1 sandbox fixture (see below) — it adds filesystem isolation at zero extra cost. This basic fixture is for minimal/legacy setups only.
python
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Required for Qt 5.x UIA support
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
# Screenshot on failure
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
# Graceful exit first, force-kill as fallback
# proc is a pywinauto Application — use wait_for_process_exit(), not 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())对于新项目,优先使用Tier 1沙箱夹具(见下文)——它无需额外成本即可实现文件系统隔离。此基础夹具仅适用于最小化/遗留设置。
python
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
config.py
python
import os
APP_PATH = os.environ.get("APP_PATH", "") # set via env — no default 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")python
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.ini
ini
[pytest]
testpaths = tests
markers =
smoke: fast smoke tests for critical paths
flaky: known-unstable tests
addopts = -v --tb=short --html=artifacts/report.html --self-contained-htmlini
[pytest]
testpaths = tests
markers =
smoke: 关键路径的快速冒烟测试
flaky: 已知不稳定的测试
addopts = -v --tb=short --html=artifacts/report.html --self-contained-htmlLocator Strategy
定位器策略
AutomationId > Name (text) > ClassName + index > XPath
(stable) (readable) (fragile) (last resort)Inspect with Accessibility Insights → Properties pane → look for first.
AutomationIdpython
undefinedAutomationId > 名称(文本) > 类名 + 索引 > XPath
(稳定) (可读) (易失效) (最后手段)使用Accessibility Insights检查 → 属性面板 → 优先查找。
AutomationIdpython
undefinedInspect at runtime — paste into a REPL to explore the tree
运行时检查 — 粘贴到REPL中探索元素树
win.print_control_identifiers()
win.print_control_identifiers()
or narrow scope:
或缩小范围:
win.child_window(auto_id="groupBox1").print_control_identifiers()
undefinedwin.child_window(auto_id="groupBox1").print_control_identifiers()
undefinedWait Patterns
等待模式
python
undefinedpython
undefinedWait for control to appear
等待控件出现
page.wait_visible(page.by_id("statusLabel"))
page.wait_visible(page.by_id("statusLabel"))
Wait for control to disappear (e.g. loading spinner)
等待控件消失(如加载动画)
page.wait_gone(page.by_id("spinnerOverlay"))
page.wait_gone(page.by_id("spinnerOverlay"))
Wait for a dialog to pop up
等待对话框弹出
dlg = page.wait_window("Confirm Delete")
dlg = page.wait_window("Confirm Delete")
Custom condition (e.g. text changes)
自定义条件(如文本变化)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
**Never use `time.sleep()` as primary synchronization** — use `wait()` or `wait_until()`.page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
**切勿将`time.sleep()`作为主要同步方式** — 使用`wait()`或`wait_until()`。Artifact Management
工件管理
python
undefinedpython
undefinedScreenshot on demand
按需截图
page.screenshot("after_login")
page.screenshot("after_login")
Full-screen capture (when window is off-screen or minimised)
全屏捕获(当窗口不在屏幕上或最小化时)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
Screen recording with ffmpeg (start before test, stop after)
使用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)
undefinedimport 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)
undefinedFlaky Test Handling
不稳定测试处理
python
undefinedpython
undefinedQuarantine — equivalent to Playwright's test.fixme()
隔离 — 等效于Playwright的test.fixme()
@pytest.mark.skip(reason="Flaky: animation race on slow CI. Issue #42")
def test_animated_transition(self, app): ...
@pytest.mark.skip(reason="不稳定:慢速CI上的动画竞争。问题#42")
def test_animated_transition(self, app): ...
Skip in CI only
仅在CI中跳过
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Flaky in CI #43")
def test_heavy_load(self, app): ...
Common causes and fixes:
| Cause | Fix |
|-------|-----|
| Control not ready | Replace `time.sleep` with `wait_visible` |
| Window not focused | Add `win.set_focus()` before interactions |
| Animation in progress | `wait_until(lambda: not loading_indicator.exists())` |
| Dialog timing | `wait_window(title, timeout=15)` |
| CI display not ready | Set `DISPLAY` or use virtual desktop in CI |@pytest.mark.skipif(os.environ.get("CI") == "true", reason="CI中不稳定 #43")
def test_heavy_load(self, app): ...
常见原因及修复方案:
| 原因 | 修复方案 |
|-------|-----|
| 控件未就绪 | 用`wait_visible`替换`time.sleep` |
| 窗口未聚焦 | 交互前添加`win.set_focus()` |
| 动画正在进行 | `wait_until(lambda: not loading_indicator.exists())` |
| 对话框时序问题 | `wait_window(title, timeout=15)` |
| CI显示未就绪 | 设置`DISPLAY`或在CI中使用虚拟桌面 |Test Isolation & Sandbox
测试隔离与沙箱
Three tiers of isolation — use the lightest tier that satisfies your needs.
三种隔离级别 — 使用满足需求的最轻量级级别。
Tier 1 — Filesystem Isolation (default, always use)
Tier 1 — 文件系统隔离(默认,始终使用)
Each test gets its own / / via and . pytest's fixture handles cleanup automatically.
APPDATALOCALAPPDATATEMPsubprocess.PopenApplication.connect()tmp_pathpython
undefined每个测试通过和获得独立的 / / 目录。pytest的夹具会自动处理清理。
subprocess.PopenApplication.connect()APPDATALOCALAPPDATATEMPtmp_pathpython
undefinedconftest.py — replace the basic app
fixture with this
appconftest.py — 用此替换基础app
夹具
appimport os, subprocess, pytest
from pywinauto import Application
from config import APP_PATH, APP_ARGS, APP_TITLE, LAUNCH_TIMEOUT, ACTION_TIMEOUT, ARTIFACT_DIR
@pytest.fixture(scope="function")
def app(request, tmp_path):
"""Fresh process + isolated user-data dirs per test."""
if not APP_PATH:
pytest.exit("APP_PATH not set", returncode=1)
# Redirect all per-user storage to an isolated tmp directory
sandbox_env = os.environ.copy()
sandbox_env["QT_ACCESSIBILITY"] = "1"
sandbox_env["APPDATA"] = str(tmp_path / "AppData" / "Roaming")
sandbox_env["LOCALAPPDATA"] = str(tmp_path / "AppData" / "Local")
sandbox_env["TEMP"] = sandbox_env["TMP"] = str(tmp_path / "Temp")
for p in (sandbox_env["APPDATA"], sandbox_env["LOCALAPPDATA"], sandbox_env["TEMP"]):
os.makedirs(p, exist_ok=True)
if not APP_TITLE:
pytest.exit("APP_TITLE environment variable is not set", returncode=1)
# shlex.split handles quoted args with spaces; plain split() breaks on them
import shlex
# Launch via subprocess so we can pass env; connect pywinauto by PID
proc = subprocess.Popen(
[APP_PATH] + shlex.split(APP_ARGS),
env=sandbox_env,
)
pw_app = Application(backend="uia").connect(process=proc.pid, timeout=LAUNCH_TIMEOUT)
win = pw_app.window(title=APP_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
try:
win.close()
proc.wait(timeout=5)
except Exception:
proc.kill()
# tmp_path is cleaned up automatically by pytest@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())
undefinedimport os, subprocess, pytest
from pywinauto import Application
from config import APP_PATH, APP_ARGS, APP_TITLE, LAUNCH_TIMEOUT, ACTION_TIMEOUT, ARTIFACT_DIR
@pytest.fixture(scope="function")
def app(request, tmp_path):
"""每个测试都有独立进程和隔离的用户数据目录。"""
if not APP_PATH:
pytest.exit("未设置APP_PATH", returncode=1)
# 将所有用户存储重定向到隔离的临时目录
sandbox_env = os.environ.copy()
sandbox_env["QT_ACCESSIBILITY"] = "1"
sandbox_env["APPDATA"] = str(tmp_path / "AppData" / "Roaming")
sandbox_env["LOCALAPPDATA"] = str(tmp_path / "AppData" / "Local")
sandbox_env["TEMP"] = sandbox_env["TMP"] = str(tmp_path / "Temp")
for p in (sandbox_env["APPDATA"], sandbox_env["LOCALAPPDATA"], sandbox_env["TEMP"]):
os.makedirs(p, exist_ok=True)
if not APP_TITLE:
pytest.exit("未设置APP_TITLE环境变量", returncode=1)
# shlex.split处理带空格的引号参数;普通split()会在空格处中断
import shlex
# 通过subprocess启动,以便传递环境变量;通过PID连接pywinauto
proc = subprocess.Popen(
[APP_PATH] + shlex.split(APP_ARGS),
env=sandbox_env,
)
pw_app = Application(backend="uia").connect(process=proc.pid, timeout=LAUNCH_TIMEOUT)
win = pw_app.window(title=APP_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
try:
win.close()
proc.wait(timeout=5)
except Exception:
proc.kill()
# tmp_path会被pytest自动清理@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())
undefinedTier 2 — Windows Job Object (optional: process-lifetime containment)
Tier 2 — Windows作业对象(可选:进程生命周期管控)
Attach the process to a Job Object so it is automatically terminated when
the test fixture's job handle is GC'd. Also prevents the app from spawning
child processes that escape fixture cleanup.
Scope of isolation: Job Objects do NOT virtualize filesystem access or block network traffic. File-write and network isolation require AppContainer, Windows Firewall rules, or Tier 3 (Windows Sandbox). Use Tier 2 only for process-lifetime and child-process containment.
Requires no extra dependencies.
python
import ctypes, ctypes.wintypes as wt
def restrict_process(pid: int):
"""
Attach the process to a Job Object that prevents it from:
- spawning processes outside the job (LIMIT_KILL_ON_JOB_CLOSE)
Does NOT block network — use Windows Firewall rules for that.
"""
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
# Minimal rights: SET_QUOTA (0x0100) | TERMINATE (0x0001)
PROCESS_SET_QUOTA_AND_TERMINATE = 0x0101
kernel32 = ctypes.windll.kernel32
job = kernel32.CreateJobObjectW(None, None)
hproc = kernel32.OpenProcess(PROCESS_SET_QUOTA_AND_TERMINATE, False, pid)
# Correct struct layout — LimitFlags is at offset +16, not +44
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", wt.LARGE_INTEGER),
("PerJobUserTimeLimit", wt.LARGE_INTEGER),
("LimitFlags", wt.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wt.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wt.DWORD),
("SchedulingClass", wt.DWORD),
]
info = JOBOBJECT_BASIC_LIMIT_INFORMATION()
info.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
ok = kernel32.SetInformationJobObject(job, 2, ctypes.byref(info), ctypes.sizeof(info))
if not ok:
raise ctypes.WinError()
kernel32.AssignProcessToJobObject(job, hproc)
kernel32.CloseHandle(hproc)
return job # keep alive — job closes (kills proc) when GC'd将进程附加到作业对象,以便当测试夹具的作业句柄被垃圾回收时自动终止进程。还能防止应用生成脱离夹具清理的子进程。
**隔离范围:**作业对象不会虚拟化文件系统访问或阻止网络流量。文件写入和网络隔离需要AppContainer、Windows防火墙规则或Tier 3(Windows沙箱)。仅在需要管控进程生命周期和子进程时使用Tier 2。
无需额外依赖。
python
import ctypes, ctypes.wintypes as wt
def restrict_process(pid: int):
"""
将进程附加到作业对象,防止其:
- 在作业外生成进程(LIMIT_KILL_ON_JOB_CLOSE)
不会阻止网络 — 如需阻止请使用Windows防火墙规则。
"""
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
# 最小权限:SET_QUOTA (0x0100) | TERMINATE (0x0001)
PROCESS_SET_QUOTA_AND_TERMINATE = 0x0101
kernel32 = ctypes.windll.kernel32
job = kernel32.CreateJobObjectW(None, None)
hproc = kernel32.OpenProcess(PROCESS_SET_QUOTA_AND_TERMINATE, False, pid)
# 正确的结构体布局 — LimitFlags位于偏移+16,而非+44
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", wt.LARGE_INTEGER),
("PerJobUserTimeLimit", wt.LARGE_INTEGER),
("LimitFlags", wt.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wt.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wt.DWORD),
("SchedulingClass", wt.DWORD),
]
info = JOBOBJECT_BASIC_LIMIT_INFORMATION()
info.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
ok = kernel32.SetInformationJobObject(job, 2, ctypes.byref(info), ctypes.sizeof(info))
if not ok:
raise ctypes.WinError()
kernel32.AssignProcessToJobObject(job, hproc)
kernel32.CloseHandle(hproc)
return job # 保持引用 — 当被垃圾回收时作业关闭(终止进程)After proc = subprocess.Popen(...): job = restrict_process(proc.pid)
在proc = subprocess.Popen(...)之后:job = restrict_process(proc.pid)
undefinedundefinedTier 3 — Windows Sandbox (CI full-OS isolation)
Tier 3 — Windows沙箱(CI全系统隔离)
When you need a clean Windows image per run (no leftover registry keys, no
shared GPU state, true isolation), run the entire test suite inside
Windows Sandbox.
Requirement: Windows 10/11 Pro or Enterprise, Virtualization enabled.
Create in your project root:
e2e-sandbox.wsbxml
<Configuration>
<MappedFolders>
<!-- App binary (read-only) -->
<MappedFolder>
<HostFolder>C:\path\to\your\build\Release</HostFolder>
<SandboxFolder>C:\app</SandboxFolder>
<ReadOnly>true</ReadOnly>
</MappedFolder>
<!-- Test suite (read-write for artifacts) -->
<MappedFolder>
<HostFolder>C:\path\to\your\e2e_test</HostFolder>
<SandboxFolder>C:\e2e_test</SandboxFolder>
<ReadOnly>false</ReadOnly>
</MappedFolder>
</MappedFolders>
<LogonCommand>
<!--
Windows Sandbox starts with no Python. Install it silently first,
then install deps and run tests. Artifacts are written back to the
host via the MappedFolder above.
-->
<Command>powershell -Command "
winget install --id Python.Python.3.11 --silent --accept-package-agreements;
$env:PATH += ';' + $env:LOCALAPPDATA + '\Programs\Python\Python311\Scripts';
cd C:\e2e_test;
pip install -r requirements.txt;
pytest tests\ -v
"</Command>
</LogonCommand>
</Configuration>Launch:
WindowsSandbox.exe e2e-sandbox.wsbpywinauto and the app both run inside the sandbox (same session required). Artifacts are written back to the host via the mapped folder.
当每次运行需要干净的Windows镜像(无残留注册表项、无共享GPU状态、真正隔离)时,在Windows沙箱中运行整个测试套件。
**要求:**Windows 10/11专业版或企业版,已启用虚拟化。
在项目根目录创建:
e2e-sandbox.wsbxml
<Configuration>
<MappedFolders>
<!-- 应用二进制文件(只读) -->
<MappedFolder>
<HostFolder>C:\path\to\your\build\Release</HostFolder>
<SandboxFolder>C:\app</SandboxFolder>
<ReadOnly>true</ReadOnly>
</MappedFolder>
<!-- 测试套件(可读写,用于存储工件) -->
<MappedFolder>
<HostFolder>C:\path\to\your\e2e_test</HostFolder>
<SandboxFolder>C:\e2e_test</SandboxFolder>
<ReadOnly>false</ReadOnly>
</MappedFolder>
</MappedFolders>
<LogonCommand>
<!--
Windows沙箱启动时没有Python。先静默安装Python,
然后安装依赖并运行测试。工件会通过映射文件夹写回主机。
-->
<Command>powershell -Command "
winget install --id Python.Python.3.11 --silent --accept-package-agreements;
$env:PATH += ';' + $env:LOCALAPPDATA + '\Programs\Python\Python311\Scripts';
cd C:\e2e_test;
pip install -r requirements.txt;
pytest tests\ -v
"</Command>
</LogonCommand>
</Configuration>启动:
WindowsSandbox.exe e2e-sandbox.wsbpywinauto和应用都在沙箱内部运行(需要同一会话)。 工件通过映射文件夹写回主机。
Tier comparison
级别对比
| Tier | Isolation | Setup cost | Works on CI | Use when |
|---|---|---|---|---|
1 — | Filesystem | Zero | Always | Default for all tests |
| 2 — Job Object | Process tree | Low | Always | Prevent child-process escape |
| 3 — Windows Sandbox | Full OS | Medium | Needs Pro/Enterprise image | Nightly clean-room runs |
| 级别 | 隔离程度 | 设置成本 | CI兼容性 | 使用场景 |
|---|---|---|---|---|
1 — | 文件系统 | 零 | 始终兼容 | 所有测试的默认方案 |
| 2 — 作业对象 | 进程树 | 低 | 始终兼容 | 防止子进程脱离管控 |
| 3 — Windows沙箱 | 全系统 | 中 | 需要专业版/企业版镜像 | 夜间干净环境运行 |
Prevent hanging tests
防止测试挂起
Add to cap any single test. In set and . Note: method cannot kill Qt app subprocesses on Windows — add in to reap orphans.
pytest-timeoutpytest.initimeout = 60timeout_method = threadthreadatexit.register(lambda: [p.kill() for p in psutil.Process().children(recursive=True)])conftest.py添加限制单个测试的运行时间。在中设置和。注意:方法无法在Windows上终止Qt应用子进程 — 在中添加以清理孤儿进程。
pytest-timeoutpytest.initimeout = 60timeout_method = threadthreadconftest.pyatexit.register(lambda: [p.kill() for p in psutil.Process().children(recursive=True)])CI/CD Integration
CI/CD集成
yaml
undefinedyaml
undefined.github/workflows/e2e-desktop.yml
.github/workflows/e2e-desktop.yml
name: Desktop E2E
on: [push, pull_request]
jobs:
e2e:
runs-on: windows-latest # real GUI environment, no Xvfb needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- name: Install deps
run: pip install pywinauto pytest pytest-html Pillow
- name: Build app
run: cmake --build build --config Release # adjust to your build system
- name: Run E2E
env:
APP_PATH: ${{ github.workspace }}\build\Release\MyApp.exe
APP_TITLE: "My Application"
CI: "true"
run: pytest tests/ --html=artifacts/report.html --self-contained-html --junitxml=artifacts/results.xml -v
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-artifacts
path: artifacts/
retention-days: 14undefinedname: Desktop E2E
on: [push, pull_request]
jobs:
e2e:
runs-on: windows-latest # 真实GUI环境,无需Xvfb
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- name: 安装依赖
run: pip install pywinauto pytest pytest-html Pillow
- name: 构建应用
run: cmake --build build --config Release # 根据你的构建系统调整
- name: 运行E2E测试
env:
APP_PATH: ${{ github.workspace }}\build\Release\MyApp.exe
APP_TITLE: "My Application"
CI: "true"
run: pytest tests/ --html=artifacts/report.html --self-contained-html --junitxml=artifacts/results.xml -v
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-artifacts
path: artifacts/
retention-days: 14undefinedQt Specific
Qt专属内容
Enable UIA in Qt 5.x
在Qt 5.x中启用UIA
Qt 5.x accessibility is disabled by default in some builds (especially 5.7–5.14). Set the environment variable before launching. Qt 6.x enables accessibility by default — skip this step for Qt 6.
python
undefinedQt 5.x的无障碍功能在某些版本中默认禁用(尤其是5.7–5.14)。启动前设置环境变量。Qt 6.x默认启用无障碍功能 — Qt 6无需此步骤。
python
undefinedconftest.py — add at module top
conftest.py — 添加到模块顶部
import os
os.environ["QT_ACCESSIBILITY"] = "1"
Or export it in CI:
```yaml
env:
QT_ACCESSIBILITY: "1"import os
os.environ["QT_ACCESSIBILITY"] = "1"
或在CI中导出:
```yaml
env:
QT_ACCESSIBILITY: "1"Add Stable Identifiers to Qt Widgets
为Qt控件添加稳定标识符
cpp
// Preferred: both objectName and accessibleName
void setTestId(QWidget* w, const char* id) {
w->setObjectName(id);
w->setAccessibleName(id); // becomes UIA Name property
}
// In your dialog constructor:
setTestId(ui->usernameEdit, "usernameInput");
setTestId(ui->passwordEdit, "passwordInput");
setTestId(ui->loginButton, "btnLogin");
setTestId(ui->errorLabel, "lblError");Centralise all IDs in a header to avoid typos:
cpp
// test_ids.h
#define TID_USERNAME "usernameInput"
#define TID_PASSWORD "passwordInput"
#define TID_BTN_LOGIN "btnLogin"
#define TID_LBL_ERROR "lblError"cpp
// 推荐:同时设置objectName和accessibleName
void setTestId(QWidget* w, const char* id) {
w->setObjectName(id);
w->setAccessibleName(id); // 变为UIA名称属性
}
// 在对话框构造函数中:
setTestId(ui->usernameEdit, "usernameInput");
setTestId(ui->passwordEdit, "passwordInput");
setTestId(ui->loginButton, "btnLogin");
setTestId(ui->errorLabel, "lblError");将所有ID集中到一个头文件中以避免拼写错误:
cpp
// test_ids.h
#define TID_USERNAME "usernameInput"
#define TID_PASSWORD "passwordInput"
#define TID_BTN_LOGIN "btnLogin"
#define TID_LBL_ERROR "lblError"Qt-Specific Quirks
Qt专属特性
QComboBox — the dropdown is a separate top-level window:
python
from pywinauto import Desktop
def select_combo_item(page, combo_spec, item_text):
page.click(combo_spec)
# Dropdown appears as a new root-level window
# class_name varies by Qt version — verify with Accessibility Insights
# Qt 5.x: "Qt5QWindowIcon" | Qt 6.x: "Qt6QWindowIcon" — verify with Accessibility Insights
popup = Desktop(backend="uia").window(class_name_re="Qt[56]QWindowIcon")
popup.wait("visible", timeout=5)
popup.child_window(title=item_text).click_input()QMessageBox / QDialog — also separate top-level windows:
python
dlg = page.wait_window("Confirm") # wait for dialog title
dlg.child_window(title="OK").click_input() # click button inside itQTableWidget / QTableView — row/cell access:
python
table = page.by_id("tblUsers").wrapper_object()
cell = table.cell(row=0, column=1)
print(cell.window_text())Self-drawn controls (-only, , ) — UIA cannot see their internals. Use the Fallback section below.
paintEventQGraphicsViewQOpenGLWidgetQComboBox — 下拉菜单是独立的顶级窗口:
python
from pywinauto import Desktop
def select_combo_item(page, combo_spec, item_text):
page.click(combo_spec)
# 下拉菜单作为新的根级窗口出现
# 类名因Qt版本而异 — 使用Accessibility Insights验证
# Qt 5.x: "Qt5QWindowIcon" | Qt 6.x: "Qt6QWindowIcon" — 使用Accessibility Insights验证
popup = Desktop(backend="uia").window(class_name_re="Qt[56]QWindowIcon")
popup.wait("visible", timeout=5)
popup.child_window(title=item_text).click_input()QMessageBox / QDialog — 同样是独立的顶级窗口:
python
dlg = page.wait_window("Confirm") # 等待对话框标题
dlg.child_window(title="OK").click_input() # 点击对话框内的按钮QTableWidget / QTableView — 行/单元格访问:
python
table = page.by_id("tblUsers").wrapper_object()
cell = table.cell(row=0, column=1)
print(cell.window_text())自绘控件(仅、、)—— UIA无法查看其内部内容。请查看下方的降级方案章节。
paintEventQGraphicsViewQOpenGLWidgetFallback: Screenshot Mode
降级方案:截图模式
When a control is not reachable via UIA (self-drawn, third-party, game engine):
bash
pip install pyautogui Pillow opencv-pythonpython
import pyautogui, cv2, numpy as np
from PIL import Image
def find_image_on_screen(template_path, confidence=0.85):
"""Locate a template image on screen. Returns (x, y) center or None."""
screen = np.array(pyautogui.screenshot())
template = np.array(Image.open(template_path))
result = cv2.matchTemplate(
cv2.cvtColor(screen, cv2.COLOR_RGB2BGR),
cv2.cvtColor(template, cv2.COLOR_RGB2BGR),
cv2.TM_CCOEFF_NORMED,
)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val >= confidence:
h, w = template.shape[:2]
return max_loc[0] + w // 2, max_loc[1] + h // 2
return None
def click_image(template_path, confidence=0.85):
pos = find_image_on_screen(template_path, confidence)
if pos is None:
raise RuntimeError(f"Image not found on screen: {template_path}")
pyautogui.click(*pos)Use sparingly — image matching breaks on DPI changes, theme switches, and partial occlusion.
Always try UIA first; fall back to screenshots only for genuinely unreachable controls.
当控件无法通过UIA访问时(自绘控件、第三方控件、游戏引擎):
bash
pip install pyautogui Pillow opencv-pythonpython
import pyautogui, cv2, numpy as np
from PIL import Image
def find_image_on_screen(template_path, confidence=0.85):
"""在屏幕上定位模板图像。返回中心坐标(x, y)或None。"""
screen = np.array(pyautogui.screenshot())
template = np.array(Image.open(template_path))
result = cv2.matchTemplate(
cv2.cvtColor(screen, cv2.COLOR_RGB2BGR),
cv2.cvtColor(template, cv2.COLOR_RGB2BGR),
cv2.TM_CCOEFF_NORMED,
)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val >= confidence:
h, w = template.shape[:2]
return max_loc[0] + w // 2, max_loc[1] + h // 2
return None
def click_image(template_path, confidence=0.85):
pos = find_image_on_screen(template_path, confidence)
if pos is None:
raise RuntimeError(f"屏幕上未找到图像:{template_path}")
pyautogui.click(*pos)谨慎使用 — 图像匹配会因DPI变化、主题切换和部分遮挡而失效。始终优先尝试UIA;仅在控件确实无法访问时才使用截图方案。
Anti-Patterns
反模式
python
undefinedpython
undefinedBAD: fixed sleep
错误:固定等待时间
time.sleep(3)
page.click(page.by_id("btnSubmit"))
time.sleep(3)
page.click(page.by_id("btnSubmit"))
GOOD: condition wait
正确:条件等待
page.wait_visible(page.by_id("btnSubmit"))
page.click(page.by_id("btnSubmit"))
```pythonpage.wait_visible(page.by_id("btnSubmit"))
page.click(page.by_id("btnSubmit"))
```pythonBAD: brittle class+index locator as primary strategy
错误:将脆弱的类+索引定位器作为主要策略
page.by_class("Edit", index=2).type_keys("hello")
page.by_class("Edit", index=2).type_keys("hello")
GOOD: AutomationId
正确:使用AutomationId
page.by_id("usernameInput").set_edit_text("hello")
```pythonpage.by_id("usernameInput").set_edit_text("hello")
```pythonBAD: assert on pixel coordinates
错误:断言像素坐标
assert btn.rectangle().left == 120
assert btn.rectangle().left == 120
GOOD: assert on content / state
正确:断言内容 / 状态
assert page.get_text(page.by_id("lblStatus")) == "Logged in"
assert page.by_id("btnLogout").is_enabled()
```pythonassert page.get_text(page.by_id("lblStatus")) == "Logged in"
assert page.by_id("btnLogout").is_enabled()
```pythonBAD: share app instance across all tests (state leaks)
错误:所有测试共享应用实例(状态泄漏)
@pytest.fixture(scope="session")
def app(): ...
@pytest.fixture(scope="session")
def app(): ...
GOOD: fresh process per test (or per class at most)
正确:每个测试使用独立进程(最多每个类共享)
@pytest.fixture(scope="function")
def app(): ...
undefined@pytest.fixture(scope="function")
def app(): ...
undefinedRunning Tests
运行测试
bash
undefinedbash
undefinedAll tests
所有测试
pytest tests/ -v
pytest tests/ -v
Smoke only
仅冒烟测试
pytest tests/ -m smoke -v
pytest tests/ -m smoke -v
Specific file
指定文件
pytest tests/test_login.py -v
pytest tests/test_login.py -v
With custom app path
使用自定义应用路径
APP_PATH="C:\build\Release\MyApp.exe" APP_TITLE="MyApp" pytest tests/ -v
APP_PATH="C:\build\Release\MyApp.exe" APP_TITLE="MyApp" pytest tests/ -v
Detect flaky tests (repeat each 5 times)
检测不稳定测试(每个测试重复5次)
pip install pytest-repeat
pytest tests/test_login.py --count=5 -v
undefinedpip install pytest-repeat
pytest tests/test_login.py --count=5 -v
undefinedRelated Skills
相关技能
- — Playwright E2E for web applications
e2e-testing - — C++ unit/integration testing with GoogleTest
cpp-testing - — C++ code style and patterns
cpp-coding-standards
- — 针对Web应用的Playwright E2E测试
e2e-testing - — 使用GoogleTest进行C++单元/集成测试
cpp-testing - — C++代码风格与模式
cpp-coding-standards