首页/测试 & QA/windows-desktop-e2e
W

windows-desktop-e2e

by @affaan-mv
4.3(120)

此技能利用 pywinauto 和 Windows UI Automation,为 WPF、WinForms、Win32/MFC 和 Qt 等 Windows 原生桌面应用程序提供端到端测试解决方案。它帮助开发者从零开始设置测试套件,诊断不稳定测试,并集成到 CI/CD 流程,确保桌面应用的质量和稳定性。

e2e-testingwindowsdesktop-automationpywinautoqaGitHub
安装方式
npx skills add https://github.com/affaan-m/everything-claude-code --skill windows-desktop-e2e
compare_arrows

Before / After 效果对比

1 组
使用前

在没有这个技能之前,用户手动测试 Windows 桌面应用程序耗时巨大且容易出错,导致发布周期延长,且难以发现深层缺陷。自动化测试的搭建和维护也面临技术门槛高、测试不稳定等痛点。

使用后

该 Skill 显著提升了 Windows 桌面应用的端到端测试效率和稳定性。通过自动化测试,团队能够快速发现并修复问题,缩短发布周期,并确保应用在不同环境下的高质量运行。

SKILL.md

Windows デスクトップ E2E テスト

pywinauto と Windows UI Automation(UIA)を使用したWindowsネイティブデスクトップアプリケーションのエンドツーエンドテスト。WPF、WinForms、Win32/MFC、Qt(5.x / 6.x)をカバーし、Qt固有のガイダンスは専用セクションとして提供します。

アクティベートするタイミング

  • Windowsネイティブデスクトップアプリケーションのエンドツーエンドテストを書くまたは実行するとき
  • デスクトップGUIテストスイートをゼロから設定するとき
  • 不安定または失敗するデスクトップオートメーションテストを診断するとき
  • 既存のアプリにテスタビリティ(AutomationId、アクセシブルな名前)を追加するとき
  • デスクトップエンドツーエンドを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要素のツリーを公開します:

テスト(Python)
    └── pywinauto(UIAバックエンド)
        └── Windows UI Automation API   ← Windowsに組み込み、フレームワーク非依存
            └── アプリのUIAプロバイダー      ← 各フレームワークが独自に実装
                └── 実行中の .exe

フレームワーク別UIA品質:

フレームワークAutomationId信頼性注記
WPF★★★★★優秀x:Name が直接AutomationIdにマッピング
WinForms★★★★☆良好AccessibleName = AutomationId
UWP / WinUI 3★★★★★優秀Microsoftの完全サポート
Qt 6.x★★★★★優秀アクセシビリティがデフォルトで有効;クラス名が Qt6* に変更
Qt 5.15+★★★★☆良好Accessibilityモジュールが改善
Qt 5.7–5.14★★★☆☆普通QT_ACCESSIBILITY=1 が必要;objectNameは手動設定
Win32 / MFC★★★☆☆普通コントロール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をインストールしてください(Microsoft提供、無料)— テストを書く前に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を優先;より豊かなサポートには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}秒以内に満たされなかった")

    # --- アクション ---

    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() ではなく wait_for_process_exit() を使用
    try:
        win.close()
        proc.wait_for_process_exit(timeout=5)
    except Exception:
        proc.kill()

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())

config.py

import os
APP_PATH          = os.environ.get("APP_PATH", "")           # 環境変数で設定 — デフォルトパスなし
MAIN_WINDOW_TITLE = os.environ.get("APP_TITLE", "")
LAUNCH_TIMEOUT    = int(os.environ.get("LAUNCH_TIMEOUT", "15"))
ACTION_TIMEOUT    = int(os.environ.get("ACTION_TIMEOUT", "10"))
ARTIFACT_DIR      = os.path.join(os.path.dirname(__file__), "artifacts")

pytest.ini

[pytest]
testpaths = tests
markers =
    smoke: 重要なパスの高速スモークテスト
    flaky: 既知の不安定なテスト
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html

ロケーター戦略

AutomationId  >  Name(テキスト)  >  ClassName + インデックス  >  XPath
  (安定)         (可読)            (脆弱)                   (最後の手段)

Accessibility Insights → Properties ペインで検査 → まず AutomationId を確認。

# 実行時の検査 — 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)

不安定なテストの対処

# 隔離 — PlaywrightのtestのFixmeと同等
@pytest.mark.skip(reason="不安定:遅いCIでのアニメーションレース。Issue #42")
def test_animated_transition(self, app): ...

# CIのみでスキップ
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="CIで不安定 #43")
def test_heavy_load(self, app): ...

一般的な原因と修正:

原因修正
コントロールが準備できていないtime.sleep を wait_visible に置き換える
ウィンドウがフォーカスされていないインタラクション前に win.set_focus() を追加する
アニメーション進行中wait_until(lambda: not loading_indicator.exists())
ダイアログのタイミングwait_window(title, timeout=15)
CI環境のディスプレイが準備できていないDISPLAY を設定するかCIで仮想デスクトップを使用する

テスト分離とサンドボックス

分離の3つの階層 — ニーズを満たす最も軽い階層を使用してください。

Tier 1 — ファイルシステム分離(デフォルト、常に使用)

各テストは subprocess.Popen と Application.connect() を通じて独自の APPDATA / LOCALAPPDATA / TEMP を取得します。pytestの tmp_path フィクスチャがクリーンアップを自動的に処理します。

# 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):
    """テストごとに新しいプロセス + 分離されたユーザーデータディレクトリ。"""
    if not APP_PATH:
        pytest.exit("APP_PATH が設定されていない", returncode=1)

    # 全てのユーザーストレージを分離されたtmpディレクトリにリダイレクト
    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はスペースを含む引用符付き引数を処理;plain 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())

Tier 2 — Windowsジョブオブジェクト(オプション:プロセスライフタイムの封じ込め)

プロセスをジョブオブジェクトにアタッチして、テストフィクスチャのジョブハンドルがGCされたときに自動的に終了させます。また、フィクスチャのクリーンアップから逃れる子プロセスのスポーンも防止します。

分離のスコープ: ジョブオブジェクトはファイルシステムアクセスの仮想化や ネットワークトラフィックのブロックを行いません。ファイル書き込みとネットワーク分離には AppContainer、Windowsファイアウォールルール、またはTier 3(Windowsサンドボックス)が必要です。 Tier 2はプロセスライフタイムと子プロセスの封じ込めにのみ使用してください。

追加の依存関係は不要です。

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  # 生存を維持 — ジョブが閉じると(GC時)プロセスが終了する

# proc = subprocess.Popen(...) の後:  job = restrict_process(proc.pid)

Tier 3 — Windowsサンドボックス(CI完全OS分離)

実行ごとにクリーンなWindowsイメージが必要な場合(残留レジストリキーなし、共有GPUステートなし、真の分離)、[Windowsサンドボックス](https://learn.microsoft.com/windows/s

...

用户评价 (0)

发表评价

效果
易用性
文档
兼容性

暂无评价

统计数据

安装量2.5K
评分4.3 / 5.0
版本
更新日期2026年8月8日
对比案例1 组

用户评分

4.3(120)
5
37%
4
43%
3
13%
2
5%
1
2%

为此 Skill 评分

0.0

兼容平台

🤖claude-code

时间线

创建2026年6月13日
最后更新2026年8月8日
🎁 Agent 知识卡片
调研问卷