Windows デスクトップ E2E テスト
pywinauto と Windows UI Automation (UIA) を利用した、Windows ネイティブ デスクトップ アプリケーションのエンドツーエンド テスト。WPF、WinForms、Win32/MFC、Qt (5.x / 6.x) をカバーし、Qt 固有のガイダンスは専用セクションとして提供します。
使用するタイミング
- Windows ネイティブ デスクトップ アプリケーションの E2E テストを作成または実行する場合
- デスクトップ GUI テストスイートをゼロからセットアップする場合
- 不安定または失敗するデスクトップ自動化テストを診断する場合
- 既存のアプリにテスト容易性(AutomationId、アクセス可能な名前)を追加する場合
- デスクトップ E2E テストを CI/CD パイプライン(GitHub Actions
windows-latest)に統合する場合
使用すべきでない場合
- Web アプリケーション →
e2e-testingスキル(Playwright)を使用 - Electron / CEF / WebView2 アプリ → HTML レイヤーはブラウザ自動化を必要とし、UIA は不適切
- モバイル アプリ → プラットフォーム固有のツール(UIAutomator、XCUITest)を使用
- 実行中の GUI を必要としない純粋なユニットテストまたは統合テスト
基本概念
すべての Windows デスクトップ自動化は、UI Automation (UIA) に依存しています。これは Windows に組み込まれたアクセシビリティ API です。サポートされているすべてのフレームワークは、Claude が読み取り、操作できるプロパティを持つ UIA 要素のツリーを公開します。
Your test (Python)
└── pywinauto (UIA backend)
└── Windows UI Automation API ← Windows に組み込み、フレームワーク非依存
└── App's UIA provider ← 各フレームワークが独自に提供
└── Running .exe
フレームワーク別の UIA 品質:
| フレームワーク | AutomationId | 信頼性 | 備考 |
|---|---|---|---|
| WPF | 5/5 | 優れている | x:Name が AutomationId に直接マッピングされる |
| WinForms | 4/5 | 良好 | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | 優れている | Microsoft の完全なサポート |
| Qt 6.x | 5/5 | 優れている | アクセシビリティがデフォルトで有効。クラス名は Qt6* に変更 |
| Qt 5.15+ | 4/5 | 良好 | 改善された Accessibility モジュール |
| Qt 5.7–5.14 | 3/5 | 普通 | QT_ACCESSIBILITY=1 が必要。objectName は手動設定 |
| Win32 / MFC | 3/5 | 普通 | コントロール ID にアクセス可能。テキストマッチングが一般的 |
セットアップと前提条件
# Python 3.8+、Windows のみ
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# オプション: 画面録画
# ffmpeg をインストールし、PATH に追加: https://ffmpeg.org/download.html
UIA が到達可能であることを確認:
from pywinauto import Desktop
Desktop(backend="uia").windows() # すべてのトップレベルウィンドウを一覧表示
Accessibility Insights for Windows(無料、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"
// Name には 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_exit() を使用 (wait_for_process() ではない)
try:
win.close()
proc.wait_for_process_exit(timeout=5)
except Exception:
proc.kill()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
config.py
import os
APP_PATH = os.environ.get("APP_PATH", "") # env 経由で設定 — デフォルトパスなし
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 (text) > ClassName + index > XPath
(安定) (可読性) (脆弱) (最終手段)
Accessibility Insights で検査 → プロパティ ペイン → 最初に AutomationId を探します。
# 実行時に検査 — REPL に貼り付けてツリーを探索
win.print_control_identifiers()
# またはスコープを絞り込む:
win.child_window(auto_id="groupBox1").print_control_identifiers()
待機パターン
# コントロールが表示されるのを待機
page.wait_visible(page.by_id("statusLabel"))
# コントロールが消えるのを待機(例: 読み込みスピナー)
page.wait_gone(page.by_id("spinnerOverlay"))
# ダイアログがポップアップするのを待機
dlg = page.wait_window("Confirm Delete")
# カスタム条件(例: テキスト変更)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
プライマリ同期として time.sleep() を使用しないでください — wait() または wait_until() を使用してください。
アーティファクト管理
# オンデマンドのスクリーンショット
page.screenshot("after_login")
# フルスクリーンキャプチャ(ウィンドウが画面外または最小化されている場合)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# ffmpeg を使用した画面録画(テスト前に開始、テスト後に停止)
import subprocess
def start_recording(name):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{name}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_recording(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
ステップトレース(オプトイン)
デフォルトの失敗スクリーンショットだけでは不安定なテストを診断するには不十分な場合があります。以下のステップレベルのトレースはデフォルトでオフです — 不安定なケースを再現する場合にのみ有効にしてください。
有効化
E2E_TRACE=1 pytest tests/test_login.py -v
# JSONL ログに入力テキストを含める(認証情報/PII を入力するテストでは使用しないでください):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
BasePage にパッチ
import os, json, time
TRACE_ENABLED = os.environ.get("E2E_TRACE") == "1"
TRACE_INCLUDE_TEXT = os.environ.get("E2E_TRACE_INCLUDE_TEXT") == "1"
class BasePage:
_step = 0
def _trace(self, action, spec=None, text=None):
if not TRACE_ENABLED:
return
BasePage._step += 1
idx = f"{BasePage._step:03d}"
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
self.window.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"step_{idx}_{action}.png"))
except Exception:
pass # キャプチャ失敗はテストを中断させない
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<redacted>" if text else None),
}
with open(os.path.join(ARTIFACT_DIR, "trace.jsonl"), "a") as f:
f.write(json.dumps(rec) + "\n")
def click(self, spec):
self.wait_visible(spec); self._trace("click_before", spec)
spec.click_input(); self._trace("click_after", spec)
def type_text(self, spec, text):
self.wait_visible(spec); self._trace("type_before", spec, text)
# ... 既存の set_edit_text / キーボードフォールバック ...
self._trace("type_after", spec)
注意点
- PII / 認証情報:
type_textの内容はデフォルトで<redacted>になります。ログインや支払いのフローではE2E_TRACE_INCLUDE_TEXT=1を決して設定しないでください。 - オーバーヘッド: アクションあたり約 50~200 ミリ秒 + ステップあたり 1 つの PNG。デフォルトの CI マトリックスでは有効にせず、専用の不安定再現ジョブでのみ有効にしてください。
- アーティファクトの肥大化: 長いフローでは数十 MB になります。
retention-daysを適宜調整してください。 - 並列/再実行の衛生: このシンプルな例では
trace.jsonlに追記し、クラスレベルのカウンターを使用しています。再実行前にアーティファクトディレクトリをクリアし、並列テストにはワーカーごとのアーティファクトディレクトリを使用してください。 - カバレッジのギャップ:
BasePageの外部で実行されたアクション(テストコード内の rawpywinauto呼び出し)はトレースされません。
不安定なテストの処理
# 隔離 — Playwright の test.fixme() に相当
@pytest.mark.skip(reason="不安定: CI が遅い場合のアニメーション競合。Issue #42")
def test_animated_transition(self, app): ...
# スキップ