Windows 데스크톱 E2E 테스트
pywinauto와 Windows UI Automation(UIA)을 백엔드로 사용하여 Windows 네이티브 데스크톱 애플리케이션의 종단간(E2E) 테스트를 수행합니다. WPF, WinForms, Win32/MFC, Qt(5.x/6.x)를 지원하며, Qt 관련 지침은 별도의 섹션으로 제공됩니다.
활성화 시기
- Windows 네이티브 데스크톱 애플리케이션에 대한 E2E 테스트 작성 또는 실행
- 데스크톱 GUI 테스트 스위트를 처음부터 구축
- 불안정하거나 실패하는 데스크톱 자동화 테스트 진단
- 기존 앱에 테스트 용이성(AutomationId, 접근 가능한 이름) 추가
- CI/CD 파이프라인(GitHub Actions
windows-latest)에 데스크톱 E2E 통합
사용하지 말아야 할 경우
- 웹 애플리케이션 →
e2e-testing스킬(Playwright) 사용 - Electron / CEF / WebView2 앱 → HTML 레이어는 브라우저 자동화가 필요하며 UIA가 적합하지 않음
- 모바일 앱 → 플랫폼별 도구(UIAutomator, XCUITest) 사용
- 실행 중인 GUI가 필요 없는 순수 단위 또는 통합 테스트
핵심 개념
모든 Windows 데스크톱 자동화는 Windows에 내장된 접근성 API인 **UI Automation(UIA)**에 의존합니다. 지원되는 모든 프레임워크는 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="로그인" />
<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 # 앱 실행 fixture, 실패 스크린샷
├── 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="메인 창"):
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 샌드박스 fixture(아래 참조)를 선호하세요. 추가 비용 없이 파일 시스템 격리를 제공합니다. 이 기본 fixture는 최소/레거시 설정에만 사용하세요.
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: 주요 경로에 대한 빠른 smoke 테스트
flaky: 알려진 불안정 테스트
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
로케이터 전략
AutomationId > Name (text) > ClassName + index > 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("삭제 확인")
# 사용자 지정 조건 (예: 텍스트 변경)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "준비")
기본 동기화로 time.sleep()을 절대 사용하지 마세요 — wait() 또는 wait_until()을 사용하세요.
아티팩트 관리
# 요청 시 스크린샷
page.screenshot("로그인_후")
# 전체 화면 캡처 (창이 화면 밖에 있거나 최소화된 경우)
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): ...
CI에서만 불안정한 테스트는 환경 변수 기반 조건부 스킵을 사용하세요:
import os
@pytest.mark.skipif(
os.environ.get("CI") == "true",
reason="로컬 전용: CI에서 불안정 (이슈 #89)"
)
def test_high_dpi_interaction(self, app): ...
Qt 특화 지침
Qt 5.x 및 6.x 모두 pywinauto UIA 백엔드를 지원하지만 중요한 차이점이 있습니다.
Qt 5.x 준비
# conftest.py 상단에 추가
import os
os.environ["QT_ACCESSIBILITY"] = "1" # Qt 5.x는 명시적 활성화 필요
os.environ["QT_LINUX_ACCESSIBILITY_ALWAYS_ON"] = "1" # Qt 5.15+ Linux
Qt 5.12 이하: objectName이 AutomationId로 노출됩니다.
Qt 5.15+ / 6.x
Qt 5.15부터 접근성 모듈이 기본적으로 활성화됩니다. Qt 6.x는 접근성을 기본적으로 활성화하며 클래스 이름이 Qt6*로 시작합니다.
# Qt 5.x vs 6.x 클래스 이름 확인
win.print_control_identidents()
# Qt6Window, Qt6Edit 등 찾기
Qt 공통 문제 해결
- 컨트롤이 표시되지 않음?
QT_ACCESSIBILITY=1이 앱 시작 전에 설정되었는지 확인하세요. - 편집 상자에 입력할 수 없음? Qt 5.x에서는 UIA 값 패턴이 불완전할 수 있습니다. 위의
type_text대체(키보드 전송)를 사용하세요. - Qt 6.x 클래스 이름? Qt 6은 클래스 이름 앞에
Qt6를 붙입니다(예:Qt6Window,Qt6Edit). 테스트를 업데이트하거나print_control_identidents()로 확인하세요. - objectName이 비어 있음?
QWidget::setObjectName()을 호출하여objectName을 설정하거나 Qt 5.x Designer UI 양식에서 설정하세요.
PySide6 / PyQt6 통합 테스트 모범 사례
import os
os.environ["QT_ACCESSIBILITY"] = "1"
from PySide6.QtWidgets import QApplication, QMainWindow, QLineEdit, QPushButton, QLabel
from pywinauto import Application
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("테스트 앱")
self.username = QLineEdit(self, objectName="usernameInput")
self.password = QLineEdit(self, objectName="passwordInput")
self.btn = QPushButton("로그인", self, objectName="btnLogin")
self.lbl = QLabel("", self, objectName="lblError")
# conftest.py에서
app = QApplication.instance() or QApplication([])
win = MainWindow(); win.show()
# pywinauto로 연결
proc = Application(backend="uia").connect(title="테스트 앱")
CI/CD 통합 (GitHub Actions)
# .github/workflows/e2e.yml 예제
name: 데스크톱 E2E
on: [push, pull_request]
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: |
pip install pywinauto pytest pytest-html Pillow
- run: |
$env:APP_PATH = "path\\to\\your\\app.exe"
$env:APP_TITLE = "앱 창 제목"
pytest tests/ --html=artifacts/report.html --self-contained-html
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-artifacts
path: artifacts/
모범 사례 요약
- 항상 AutomationId 사용 — 가장 안정적인 로케이터입니다.
- time.sleep() 대신 wait_ 사용* — 동기화 기본입니다.
- 실패 시 스크린샷 캡처 — CI 디버깅에 매우 유용합니다.
- 로컬에서 Accessibility Insights로 검사 — 첫 번째 테스트 전에 UIA 트리를 이해하세요.
- 페이지 객체 모델 사용 — 모든 프레임워크에서 테스트를 유지 관리하기 쉽게 만듭니다.
- CI에서 불안정한 테스트 격리 — 환경 변수 또는 pytest 마커를 사용하세요.
- Qt는 명시적으로 활성화 —
QT_ACCESSIBILITY=1을 잊지 마세요. - 추적을 드물게 사용 — 불안정한 재현 중에만 활성화하세요.
- 가상 데스크톱에서 병렬 실행 — 테스트 격리를 위해 Windows 샌드박스 또는 Docker Desktop을 사용하세요.
- Accessibility Insights로 실제 사용자처럼 탐색 — 클릭에 의존하지 마세요.