Windows 데스크톱 E2E 테스트
pywinauto와 Windows UI Automation(UIA)을 사용한 Windows 네이티브 데스크톱 애플리케이션의 종단간(E2E) 테스트입니다. WPF, WinForms, Win32/MFC 및 Qt(5.x/6.x)를 포괄하며, Qt 관련 지침은 별도 섹션으로 제공됩니다.
활성화 시점
- Windows 네이티브 데스크톱 애플리케이션용 E2E 테스트를 작성하거나 실행할 때
- 데스크톱 GUI 테스트 스위트를 처음부터 설정할 때
- 불안정하거나 실패하는 데스크톱 자동화 테스트를 진단할 때
- 기존 앱에 테스트 용이성(AutomationId, 접근 가능한 이름)을 추가할 때
- 데스크톱 E2E를 CI/CD 파이프라인(GitHub Actions
windows-latest)에 통합할 때
사용하지 말아야 할 때
- 웹 애플리케이션 →
e2e-testing스킬(Playwright) 사용 - Electron/CEF/WebView2 앱 → HTML 레이어는 브라우저 자동화가 필요하며 UIA가 적합하지 않음
- 모바일 앱 → 플랫폼별 도구(UIAutomator, XCUITest) 사용
- 실행 중인 GUI가 필요 없는 순수 단위 또는 통합 테스트
핵심 개념
모든 Windows 데스크톱 자동화는 **UI Automation(UIA)**에 의존합니다. UIA는 Windows에 내장된 접근성 API입니다. 지원되는 모든 프레임워크는 Claude가 읽고 조작할 수 있는 속성을 가진 UIA 요소 트리를 노출합니다.
테스트 (Python)
└── pywinauto (UIA 백엔드)
└── Windows UI Automation API ← Windows에 내장, 프레임워크에 무관
└── 앱의 UIA 공급자 ← 각 프레임워크가 자체 공급자 제공
└── 실행 중인 .exe
프레임워크별 UIA 품질:
| 프레임워크 | AutomationId | 신뢰도 | 비고 |
|---|---|---|---|
| WPF | 5/5 | 우수 | x:Name이 AutomationId에 직접 매핑됨 |
| WinForms | 4/5 | 양호 | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | 우수 | Microsoft의 완전한 지원 |
| Qt 6.x | 5/5 | 우수 | 접근성 기본 활성화; 클래스 이름이 Qt6*로 변경됨 |
| Qt 5.15+ | 4/5 | 양호 | 향상된 접근성 모듈 |
| Qt 5.7–5.14 | 3/5 | 보통 | QT_ACCESSIBILITY=1 필요; objectName 수동 설정 |
| Win32 / MFC | 3/5 | 보통 | 컨트롤 ID 접근 가능; 텍스트 매칭 일반적 |
설정 및 전제 조건
# Python 3.8+, Windows 전용
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# 선택 사항: 화면 녹화
# ffmpeg 설치 후 PATH에 추가: https://ffmpeg.org/download.html
UIA에 접근 가능한지 확인합니다.
from pywinauto import Desktop
Desktop(backend="uia").windows() # 모든 최상위 창 나열
Accessibility Insights for Windows(무료, 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 값 패턴이 불완전할 수 있음
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text 실패 ({e}), 키보드 대체 사용", file=sys.stderr)
ctrl.click_input()
kb.send_keys("^a")
kb.send_keys(text, with_spaces=True)
def get_text(self, spec):
ctrl = spec.wrapper_object()
for attr in ("window_text", "get_value"):
try:
v = getattr(ctrl, attr)()
if v:
return v
except Exception:
pass
return ""
# --- 아티팩트 ---
def screenshot(self, name):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
path = os.path.join(ARTIFACT_DIR, f"{name}.png")
self.window.capture_as_image().save(path)
return path
login_page.py
from pages.base_page import BasePage
class LoginPage(BasePage):
@property
def username(self): return self.by_id("usernameInput")
@property
def password(self): return self.by_id("passwordInput")
@property
def btn_login(self): return self.by_id("btnLogin")
@property
def error_label(self): return self.by_id("lblError")
def login(self, user, pwd):
self.type_text(self.username, user)
self.type_text(self.password, pwd)
self.click(self.btn_login)
def login_ok(self, user, pwd, main_title="Main Window"):
self.login(user, pwd)
return self.wait_window(main_title)
def login_fail(self, user, pwd):
self.login(user, pwd)
self.wait_visible(self.error_label)
return self.get_text(self.error_label)
conftest.py
새 프로젝트의 경우 Tier 1 샌드박스 픽스쳐(아래 참조)를 선호합니다. 추가 비용 없이 파일 시스템 격리를 제공합니다. 이 기본 픽스쳐는 최소/레거시 설정에만 사용하세요.
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Qt 5.x UIA 지원에 필요
from pywinauto import Application
from config import APP_PATH, MAIN_WINDOW_TITLE, LAUNCH_TIMEOUT, ARTIFACT_DIR
@pytest.fixture
def app(request):
if not APP_PATH:
pytest.exit("APP_PATH 환경 변수가 설정되지 않았습니다", returncode=1)
proc = Application(backend="uia").start(APP_PATH, timeout=LAUNCH_TIMEOUT)
win = proc.window(title=MAIN_WINDOW_TITLE)
win.wait("visible", timeout=LAUNCH_TIMEOUT)
yield win
# 실패 시 스크린샷
if getattr(getattr(request.node, "rep_call", None), "failed", False):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
win.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"FAIL_{request.node.name}.png")
)
except Exception:
pass
# 정상 종료 먼저 시도, 실패 시 강제 종료
# proc는 pywinauto Application 객체 — wait_for_process_exit() 사용, wait_for_process() 사용 금지
try:
win.close()
proc.wait_for_process_exit(timeout=5)
except Exception:
proc.kill()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
config.py
import os
APP_PATH = os.environ.get("APP_PATH", "") # 환경 변수로 설정 — 기본 경로 없음
MAIN_WINDOW_TITLE = os.environ.get("APP_TITLE", "")
LAUNCH_TIMEOUT = int(os.environ.get("LAUNCH_TIMEOUT", "15"))
ACTION_TIMEOUT = int(os.environ.get("ACTION_TIMEOUT", "10"))
ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
pytest.ini
[pytest]
testpaths = tests
markers =
smoke: 주요 경로에 대한 빠른 스모크 테스트
flaky: 알려진 불안정 테스트
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
로케이터 전략
AutomationId > Name (텍스트) > ClassName + 인덱스 > XPath
(안정적) (가독성 좋음) (취약함) (최후의 수단)
Accessibility Insights로 검사 → 속성 창 → 먼저 AutomationId를 찾습니다.
# 런타임에 검사 — REPL에 붙여넣어 트리 탐색
win.print_control_identifiers()
# 또는 범위를 좁힘:
win.child_window(auto_id="groupBox1").print_control_identifiers()
대기 패턴
# 컨트롤이 나타날 때까지 대기
page.wait_visible(page.by_id("statusLabel"))
# 컨트롤이 사라질 때까지 대기 (예: 로딩 스피너)
page.wait_gone(page.by_id("spinnerOverlay"))
# 대화상자가 나타날 때까지 대기
dlg = page.wait_window("Confirm Delete")
# 사용자 정의 조건 (예: 텍스트 변경)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
기본 동기화 수단으로 time.sleep()을 절대 사용하지 마세요. 대신 wait() 또는 wait_until()을 사용하세요.
아티팩트 관리
# 요청 시 스크린샷
page.screenshot("after_login")
# 전체 화면 캡처 (창이 화면 밖에 있거나 최소화된 경우)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# ffmpeg를 사용한 화면 녹화 (테스트 전 시작, 후 중지)
import subprocess
def start_recording(name):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{name}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_recording(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
단계별 추적 (옵트인)
기본 실패 스크린샷만으로는 불안정한 테스트를 진단하기에 충분하지 않은 경우가 많습니다. 아래 단계 수준 추적은 기본적으로 비활성화되어 있습니다. 불안정한 케이스를 재현할 때만 활성화하세요.
활성화
E2E_TRACE=1 pytest tests/test_login.py -v
# JSONL 로그에 입력된 텍스트 포함 (자격 증명/PII가 있는 테스트에는 사용하지 마세요):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
BasePage에 패치
import os, json, time
TRACE_ENABLED = os.environ.get("E2E_TRACE") == "1"
TRACE_INCLUDE_TEXT = os.environ.get("E2E_TRACE_INCLUDE_TEXT") == "1"
class BasePage:
_step = 0
def _trace(self, action, spec=None, text=None):
if not TRACE_ENABLED:
return
BasePage._step += 1
idx = f"{BasePage._step:03d}"
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
self.window.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"step_{idx}_{action}.png"))
except Exception:
pass # 캡처 실패가 테스트를 중단시켜서는 안 됨
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<redacted>" if text else None),
}
with open(os.path.join(ARTIFACT_DIR, "trace.jsonl"), "a") as f:
f.write(json.dumps(rec) + "\n")
def click(self, spec):
self.wait_visible(spec); self._trace("click_before", spec)
spec.click_input(); self._trace("click_after", spec)
def type_text(self, spec, text):
self.wait_visible(spec); self._trace("type_before", spec, text)
# ... 기존 set_edit_text / 키보드 대체 ...
self._trace("type_after", spec)
주의 사항
- PII / 자격 증명:
type_text내용은 기본적으로<redacted>로 표시됩니다. 로그인이나 결제 흐름에서는 절대E2E_TRACE_INCLUDE_TEXT=1을 설정하지 마세요. - 오버헤드: 액션당 약 50~200ms + 단계당 하나의 PNG 파일이 디스크에 생성됩니다. 기본 CI 매트릭스에서는 활성화하지 말고, 전용 불안정 재현 작업에서만 사용하세요.
- 아티팩트 증가: 긴 흐름은 수십 MB를 생성할 수 있습니다. 이에 따라
retention-days를 조정하세요. - 병렬/재실행 위생: 이 간단한 예제는
trace.jsonl에 추가하고 클래스 수준 카운터를 사용합니다. 재실행 전에 아티팩트 디렉터리를 정리하고, 병렬 테스트에는 작업자별 아티팩트 디렉터리를 사용하세요. - 커버리지 격차:
BasePage외부에서 수행된 액션(테스트 코드의 원시pywinauto호출)은 추적되지 않습니다.
불안정한 테스트 처리
# 격리 — Playwright의 test.fixme()와 동일
@pytest.mark.skip(reason="불안정: 느린 CI에서 애니메이션 경쟁 상태. 문제 #42")
def test_animated_transition(self, app): ...
# 건너뛰기 (marker가 이미 "flaky"로 정의됨)
@pytest.mark.flaky(reruns=2, reruns_delay=5)
def test_unstable_grid_sort(self, app): ...
CI 통합 (GitHub Actions)
name: Desktop E2E
on: [push, pull_request]
jobs:
e2e:
runs-on: windows-latest
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: 내장 .exe 빌드 (또는 다운로드)
run: dotnet build src/MyApp.sln -c Release
- name: E2E 테스트 실행
env:
APP_PATH: src/MyApp/bin/Release/MyApp.exe
APP_TITLE: My Application
run: pytest tests/ -v
- name: 아티팩트 업로드
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-artifacts
path: tests/artifacts/
Qt 전용 섹션
Qt 애플리케이션(5.x 및 6.x)은 기본적인 프레임워크 중립 접근 방식 외에 특별한 주의가 필요합니다.
필수 설정
# Qt 5.x: pywinauto를 임포트하기 전에 설정
import os
os.environ["QT_ACCESSIBILITY"] = "1"
# Qt 6.x: 접근성이 기본적으로 활성화되어 있으며 env는 무시됩니다.
# UIA 플러그인은 Qt 6.3+에서만 지원됩니다.
Qt 5.x CLI 인수
# .exe에 전달:
MyApp.exe -platform windows:uiatrace=1
Qt 5.x 컴파일 시간 (선택 사항이지만 권장)
# .pro 파일:
QT += accessibility
DEFINES += QT_ACCESSIBILITY_SUPPORT
Qt 5.x 확인
test_app.py에 추가:
def test_uia_accessible(app):
"""QWidget에 접근 가능한 이름이 있고 UIA가 작동하는지 확인"""
main = app.window()
controls = main.descendants()
# Qt는 고유한 프레임워크 클래스 이름을 사용함
qt_classes = [c.class_name() for c in controls if "Qt" in c.class_name()]
assert len(qt_classes) > 0, f"Qt 컨트롤 없음. QT_ACCESSIBILITY가 설정되었나요? 컨트롤: {[c.class_name() for c in controls[:10]]}"
Qt 5.x 제한 사항 (5.7–5.14)
objectName이 UIAAutomationId에 매핑되지 않음 → pywinauto 쿼리에서auto_id=대신class_name=+found_index=를 사용해야 함type_text의set_edit_text가 실패 → **base_page.py의type_text**가 키보드 대체로 자동 처리- 일부 Qt 위젯(예: QTableWidget 헤더)이 UIA 트리에 전혀 없음
- 진행률 표시줄과 같은 표준 위젯은 UIA 값 패턴을 누락할 수 있음
Qt 5.x–6.x 차이점
| 측면 | Qt 5.x | Qt 6.x |
|---|---|---|
| 접근성 기본값 | 비활성화 (QT_ACCESSIBILITY=1 필요) | 활성화 |
| 클래스 이름 | Qt5* (예: Qt5QLineEdit) | Qt6* (예: Qt6QLineEdit) |
| UIA 플러그인 | 없음 (기본 MSAA/UIA) | Qt 6.3+에서 Windows UI Automation 플러그인 |
| objectName 매핑 | 버전에 따라 다름 (대개 건너뜀) | 6.3+에서 부분적으로 지원 |
Qt 6.x 모범 사례
# Qt 6.x 더 안정적인 로케이터
user = main.child_window(class_name="Qt6QLineEdit", found_index=0)
# 또는 가능한 경우 class_name이 충분히 고유하면:
user = main.child_window(class_name="Qt6QLineEdit")