windows-desktop-e2e

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Windows 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
    e2e-testing
    skill (Playwright)
  • 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应用 → 使用
    e2e-testing
    技能(Playwright)
  • 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 .exe
UIA quality by framework:
FrameworkAutomationIdReliabilityNotes
WPF★★★★★Excellent
x:Name
maps directly to AutomationId
WinForms★★★★☆Good
AccessibleName
= AutomationId
UWP / WinUI 3★★★★★ExcellentFull Microsoft support
Qt 6.x★★★★★ExcellentAccessibility enabled by default; class names change to
Qt6*
Qt 5.15+★★★★☆GoodImproved Accessibility module
Qt 5.7–5.14★★★☆☆FairNeeds
QT_ACCESSIBILITY=1
; objectName manual
Win32 / MFC★★★☆☆FairControl 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★★★★★优秀
x:Name
直接映射为AutomationId
WinForms★★★★☆良好
AccessibleName
= AutomationId
UWP / WinUI 3★★★★★优秀微软官方全面支持
Qt 6.x★★★★★优秀默认启用无障碍功能;类名变为
Qt6*
Qt 5.15+★★★★☆良好无障碍模块已改进
Qt 5.7–5.14★★★☆☆一般需要设置
QT_ACCESSIBILITY=1
;需手动配置objectName
Win32 / MFC★★★☆☆一般可访问控件ID;常用文本匹配

Setup & Prerequisites

设置与前置条件

bash
undefined
bash
undefined

Python 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 windows
Install 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 support
cpp
// .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, logs
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

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 path
python
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 path

login_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-html
ini
[pytest]
testpaths = tests
markers =
    smoke: 关键路径的快速冒烟测试
    flaky: 已知不稳定的测试
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html

Locator Strategy

定位器策略

AutomationId  >  Name (text)  >  ClassName + index  >  XPath
  (stable)         (readable)       (fragile)           (last resort)
Inspect with Accessibility Insights → Properties pane → look for
AutomationId
first.
python
undefined
AutomationId  >  名称(文本)  >  类名 + 索引  >  XPath
  (稳定)         (可读)       (易失效)           (最后手段)
使用Accessibility Insights检查 → 属性面板 → 优先查找
AutomationId
python
undefined

Inspect 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()
undefined
win.child_window(auto_id="groupBox1").print_control_identifiers()
undefined

Wait Patterns

等待模式

python
undefined
python
undefined

Wait 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
undefined
python
undefined

Screenshot 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)
undefined
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)
undefined

Flaky Test Handling

不稳定测试处理

python
undefined
python
undefined

Quarantine — 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
APPDATA
/
LOCALAPPDATA
/
TEMP
via
subprocess.Popen
and
Application.connect()
. pytest's
tmp_path
fixture handles cleanup automatically.
python
undefined
每个测试通过
subprocess.Popen
Application.connect()
获得独立的
APPDATA
/
LOCALAPPDATA
/
TEMP
目录。pytest的
tmp_path
夹具会自动处理清理。
python
undefined

conftest.py — replace the basic
app
fixture with this

conftest.py — 用此替换基础
app
夹具

import 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())
undefined
import 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())
undefined

Tier 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)

undefined
undefined

Tier 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
e2e-sandbox.wsb
in your project root:
xml
<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.wsb
pywinauto 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.wsb
xml
<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.wsb
pywinauto和应用都在沙箱内部运行(需要同一会话)。 工件通过映射文件夹写回主机。

Tier comparison

级别对比

TierIsolationSetup costWorks on CIUse when
1 —
tmp_path
env redirect
FilesystemZeroAlwaysDefault for all tests
2 — Job ObjectProcess treeLowAlwaysPrevent child-process escape
3 — Windows SandboxFull OSMediumNeeds Pro/Enterprise imageNightly clean-room runs
级别隔离程度设置成本CI兼容性使用场景
1 —
tmp_path
环境重定向
文件系统始终兼容所有测试的默认方案
2 — 作业对象进程树始终兼容防止子进程脱离管控
3 — Windows沙箱全系统需要专业版/企业版镜像夜间干净环境运行

Prevent hanging tests

防止测试挂起

Add
pytest-timeout
to cap any single test. In
pytest.ini
set
timeout = 60
and
timeout_method = thread
. Note:
thread
method cannot kill Qt app subprocesses on Windows — add
atexit.register(lambda: [p.kill() for p in psutil.Process().children(recursive=True)])
in
conftest.py
to reap orphans.
添加
pytest-timeout
限制单个测试的运行时间。在
pytest.ini
中设置
timeout = 60
timeout_method = thread
。注意:
thread
方法无法在Windows上终止Qt应用子进程 — 在
conftest.py
中添加
atexit.register(lambda: [p.kill() for p in psutil.Process().children(recursive=True)])
以清理孤儿进程。

CI/CD Integration

CI/CD集成

yaml
undefined
yaml
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: 14
undefined
name: 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: 14
undefined

Qt 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
undefined
Qt 5.x的无障碍功能在某些版本中默认禁用(尤其是5.7–5.14)。启动前设置环境变量。Qt 6.x默认启用无障碍功能 — Qt 6无需此步骤。
python
undefined

conftest.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 it
QTableWidget / 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 (
paintEvent
-only,
QGraphicsView
,
QOpenGLWidget
) — UIA cannot see their internals. Use the Fallback section below.
QComboBox — 下拉菜单是独立的顶级窗口:
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())
自绘控件(仅
paintEvent
QGraphicsView
QOpenGLWidget
)—— UIA无法查看其内部内容。请查看下方的降级方案章节。

Fallback: Screenshot Mode

降级方案:截图模式

When a control is not reachable via UIA (self-drawn, third-party, game engine):
bash
pip install pyautogui Pillow opencv-python
python
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-python
python
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
undefined
python
undefined

BAD: 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"))

```python
page.wait_visible(page.by_id("btnSubmit")) page.click(page.by_id("btnSubmit"))

```python

BAD: 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")

```python
page.by_id("usernameInput").set_edit_text("hello")

```python

BAD: 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()

```python
assert page.get_text(page.by_id("lblStatus")) == "Logged in" assert page.by_id("btnLogout").is_enabled()

```python

BAD: 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(): ...
undefined

Running Tests

运行测试

bash
undefined
bash
undefined

All 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
undefined
pip install pytest-repeat pytest tests/test_login.py --count=5 -v
undefined

Related Skills

相关技能

  • e2e-testing
    — Playwright E2E for web applications
  • cpp-testing
    — C++ unit/integration testing with GoogleTest
  • cpp-coding-standards
    — C++ code style and patterns
  • e2e-testing
    — 针对Web应用的Playwright E2E测试
  • cpp-testing
    — 使用GoogleTest进行C++单元/集成测试
  • cpp-coding-standards
    — C++代码风格与模式