Windows Desktop-E2E-Testing
End-to-End-Tests für native Windows-Desktopanwendungen mit pywinauto und dem Windows UI Automation (UIA) Backend. Unterstützt WPF, WinForms, Win32/MFC und Qt (5.x / 6.x) — mit einem eigenen Abschnitt für Qt-spezifische Anleitungen.
Wann aktivieren
- Schreiben oder Ausführen von E2E-Tests für eine native Windows-Desktopanwendung
- Einrichten einer Desktop-GUI-Testsuite von Grund auf
- Diagnose flaky oder fehlschlagender Desktop-Automatisierungstests
- Hinzufügen von Testbarkeit (AutomationId, barrierefreie Namen) zu einer bestehenden App
- Integration von Desktop-E2E in eine CI/CD-Pipeline (GitHub Actions
windows-latest)
Wann NICHT verwenden
- Webanwendungen → Fertigkeit
e2e-testing(Playwright) verwenden - Electron / CEF / WebView2-Apps → die HTML-Schicht benötigt Browser-Automation, nicht UIA
- Mobile Apps → plattformspezifische Tools verwenden (UIAutomator, XCUITest)
- Reine Unit- oder Integrationstests, die keine laufende GUI benötigen
Grundlegende Konzepte
Jede Windows-Desktop-Automation basiert auf UI Automation (UIA), einer in Windows integrierten barrierefreien API. Jedes unterstützte Framework stellt einen Baum von UIA-Elementen mit Eigenschaften bereit, die Claude lesen und darauf reagieren kann:
Ihr Test (Python)
└── pywinauto (UIA-Backend)
└── Windows-UI-Automation-API ← in Windows integriert, framework-unabhängig
└── UIA-Anbieter der App ← jedes Framework liefert seinen eigenen
└── Ausführbare .exe
UIA-Qualität nach Framework:
| Framework | AutomationId | Zuverlässigkeit | Hinweise |
|---|---|---|---|
| WPF | 5/5 | Hervorragend | x:Name wird direkt auf AutomationId abgebildet |
| WinForms | 4/5 | Gut | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | Hervorragend | Volle Microsoft-Unterstützung |
| Qt 6.x | 5/5 | Hervorragend | Barrierefreiheit standardmäßig aktiviert; Klassennamen ändern sich zu Qt6* |
| Qt 5.15+ | 4/5 | Gut | Verbessertes Accessibility-Modul |
| Qt 5.7–5.14 | 3/5 | Mittel | Benötigt QT_ACCESSIBILITY=1; objectName manuell |
| Win32 / MFC | 3/5 | Mittel | Steuerelement-IDs zugänglich; Textabgleich üblich |
Einrichtung und Voraussetzungen
# Python 3.8+, nur Windows
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Optional: Bildschirmaufzeichnung
# ffmpeg installieren und zum PATH hinzufügen: https://ffmpeg.org/download.html
Überprüfen, ob UIA erreichbar ist:
from pywinauto import Desktop
Desktop(backend="uia").windows() # listet alle Top-Level-Fenster
Accessibility Insights for Windows installieren (kostenlos, von Microsoft) — Ihr DevTools-Äquivalent zum Inspizieren des UIA-Elementbaums, bevor Sie einen Test schreiben.
Einrichtung der Testbarkeit (nach Framework)
Das Wichtigste, was Sie tun können, ist, jedem interaktiven Steuerelement vor dem Schreiben von Tests eine stabile AutomationId zu geben.
WPF
<!-- XAML: x:Name wird automatisch zur AutomationId -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Anmelden" />
<TextBlock x:Name="lblError" />
WinForms
// Im Designer oder Code festlegen
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Steuerelement-Ressourcen-IDs in der .rc-Datei werden als AutomationId-Strings bereitgestellt
// IDC_EDIT_USERNAME -> AutomationId "1001"
// SetWindowText für Name bevorzugen; IAccessible für umfangreichere Unterstützung hinzufügen
Qt — siehe dedizierten Abschnitt unten
Page-Object-Modell
tests/
├── conftest.py # App-Start-Fixture, Fehler-Screenshot
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # für Importe erforderlich
│ ├── base_page.py # Locators, Warten, Screenshot-Helfer
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # Screenshots, Videos, Logs
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
# --- Locators (Prioritätsreihenfolge) ---
def by_id(self, auto_id, **kw):
"""AutomationId — am stabilsten. Als erste Wahl verwenden."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Sichtbarer Text / barrierefreier Name."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Steuerelementklasse + Index — instabil, wenn möglich vermeiden."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Warten ---
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):
"""Auf ein neues Top-Level-Fenster warten (Dialoge, Unterfenster)."""
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):
"""Auf eine beliebige Bedingung warten — verwenden, wenn UIA-Ereignisse unzuverlässig sind."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if fn():
return True
except Exception:
pass
time.sleep(interval)
raise TimeoutError(f"Bedingung nicht innerhalb von {timeout}s erfüllt")
# --- Aktionen ---
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-Wertmuster kann unvollständig sein
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text fehlgeschlagen ({e}), verwende Tastatur-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 ""
# --- Artefakte ---
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="Hauptfenster"):
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
Für neue Projekte bevorzugen Sie die Tier-1-Sandbox-Fixture (siehe unten) — sie fügt Dateisystemisolierung ohne zusätzliche Kosten hinzu. Diese grundlegende Fixture ist nur für minimale/ Legacy-Einrichtungen gedacht.
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Erforderlich für Qt 5.x UIA-Unterstützung
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-Umgebungsvariable ist nicht gesetzt", 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 bei Fehler
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
# Zuerst sauber beenden, dann notfalls killen
# proc ist eine pywinauto Application — wait_for_process_exit() verwenden, nicht 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", "") # über env setzen — kein Standardpfad
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: schnelle Smoke-Tests für kritische Pfade
flaky: bekannte instabile Tests
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Locator-Strategie
AutomationId > Name (Text) > ClassName + Index > XPath
(stabil) (lesbar) (instabil) (letzter Ausweg)
Mit Accessibility Insights inspizieren → Bereich Eigenschaften → zuerst nach AutomationId suchen.
# Zur Laufzeit inspizieren — in ein REPL einfügen, um den Baum zu erkunden
win.print_control_identifiers()
# oder den Bereich eingrenzen:
win.child_window(auto_id="groupBox1").print_control_identifiers()
Wartemuster
# Auf ein Steuerelement warten
page.wait_visible(page.by_id("statusLabel"))
# Warten, bis ein Steuerelement verschwindet (z. B. Lade-Spinner)
page.wait_gone(page.by_id("spinnerOverlay"))
# Auf ein Dialogfenster warten
dlg = page.wait_window("Löschen bestätigen")
# Benutzerdefinierte Bedingung (z. B. Textänderungen)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Bereit")
Verwenden Sie niemals time.sleep() als primäre Synchronisation — verwenden Sie wait() oder wait_until().
Artefaktverwaltung
# Screenshot bei Bedarf
page.screenshot("after_login")
# Vollbildaufnahme (wenn das Fenster außerhalb des Bildschirms oder minimiert ist)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Bildschirmaufzeichnung mit ffmpeg (vor dem Test starten, nach dem Test stoppen)
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)
Schritt-für-Schritt-Ablaufverfolgung (opt-in)
Der Standard-Fehler-Screenshot ist oft zu dünn, um flaky Tests zu diagnostizieren. Die untenstehende Schritt-für-Schritt-Ablaufverfolgung ist standardmäßig deaktiviert — aktivieren Sie sie nur, wenn Sie einen flaky Fall reproduzieren möchten.
Aktivieren
E2E_TRACE=1 pytest tests/test_login.py -v
# Getippten Text in das JSONL-Log aufnehmen (NICHT bei Tests verwenden, die Anmeldedaten/PII eingeben):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
In BasePage einfügen
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 # Fehler bei der Aufnahme darf den Test nicht unterbrechen
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)
# ... bestehender set_edit_text / Tastatur-Fallback ...
self._trace("type_after", spec)
Einschränkungen
- PII / Anmeldedaten: Der Inhalt von
type_textist standardmäßig<redacted>. Setzen SieE2E_TRACE_INCLUDE_TEXT=1niemals bei Anmelde- oder Zahlungsvorgängen. - Overhead: ~50–200ms pro Aktion + ein PNG pro Schritt auf der Festplatte. Nicht in der standardmäßigen CI-Matrix aktivieren — nur in einem dedizierten Job zur Flaky-Reproduktion.
- Artefaktaufblähung: Ein langer Ablauf produziert dutzende MB; passen Sie
retention-daysentsprechend an. - Parallel-/Wiederholungshygiene: Dieses einfache Beispiel hängt an
trace.jsonlan und verwendet einen Klassen-Zähler. Leeren Sie das Artefaktverzeichnis vor Wiederholungen und verwenden Sie für parallele Tests pro-Worker-Artefaktverzeichnisse. - Abdeckungslücke: Aktionen, die außerhalb von
BasePageausgeführt werden (reinepywinauto-Aufrufe im Testcode), werden nicht verfolgt.
Umgang mit flaky Tests
# Quarantäne — entspricht Playwrights test.fixme()
@pytest.mark.skip(reason="Flaky: Animationswettlauf auf langsamer CI. Issue #42")
def test_animated_transition(self, app): ...
# Überspringen, wenn eine Bedingung nicht erfüllt ist
@pytest.mark.skipif("os.environ.get('CI') == 'true' and os.environ.get('RUNNER_DEBUG') != '1'", reason="Nicht reproduzierbar im CI ohne Debug-Modus")
def test_unstable_feature(self, app): ...
Wenn alles andere fehlschlägt: starten Sie die App mit der Tier-1-Sandbox-Fixture (siehe separates Hilfsblatt), die eine vollständige Dateisystem-, Registrierungs- und Prozessisolierung mit Sandboxie Plus hinzufügt.
Qt-spezifische Anleitung
Qt 6.x
// CMakeLists.txt — Qt6 ist standardmäßig barrierefrei
find_package(Qt6 REQUIRED COMPONENTS Widgets)
// Die QAccessible-Schnittstelle ist standardmäßig aktiviert.
// QWidget::setObjectName() wird zu AutomationId.
ui->usernameEdit->setObjectName("usernameEdit");
ui->passwordEdit->setObjectName("passwordEdit");
ui->loginButton->setObjectName("loginButton");
ui->statusLabel->setObjectName("statusLabel");
Verwenden Sie Accessible Rich Internet Applications (ARIA)-Attribute, wenn Sie QML für Qt Quick / Qt Quick Controls 2 haben.
Qt 5.x (5.15+ empfohlen)
# Python-Testcode — zuerst aktivieren
os.environ["QT_ACCESSIBILITY"] = "1"
// C++-Anwendungscode
#include <QAccessible>
// Stellen Sie sicher, dass das Qt5-Plug-in accessiblebridge geladen ist
// (standardmäßig auf Windows enthalten)
Wichtige Kompromisse bei Qt 5:
QComboBox-Dropdowns öffnen sich möglicherweise als untergeordnete Fenster; verwenden Siewait_window("Name", um sie zu erfassen.- Klassenname ändert sich zwischen Qt5 und Qt6:
QLineEdit→Qt5QLineEditoderQt6QLineEdit. Verlassen Sie sich nicht auf Klassennamen. - ValuePattern für QLineEdit: pywinautos
set_edit_text()kann fehlschlagen; der oben gezeigte Tastatur-Fallback inBasePage.type_text()funktioniert zuverlässig. set_edit_text()funktioniert korrekt mitQPlainTextEditundQTextEdit, aber fürQLineEditkann das Value Pattern fehlen, wenn das Qt-Plug-in nicht richtig geladen ist.
CI/CD: GitHub Actions
name: Desktop E2E
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: "**/requirements*.txt"
- run: pip install pywinauto pytest pytest-html Pillow
- name: E2E-Tests ausführen
env:
APP_PATH: ./MyApp/bin/Release/MyApp.exe
APP_TITLE: MyApp
LAUNCH_TIMEOUT: 30
ACTION_TIMEOUT: 15
run: pytest tests/ -v --tb=short --html=artifacts/report.html --self-contained-html
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-artifacts
path: artifacts/
retention-days: 7
Wichtige CI-Hinweise:
- Windows-Image:
windows-latestenthält .NET Desktop Runtime, C++ Redistributables und Qt-Runtime (falls Teil der Abhängigkeiten). - GPU vs CPU: Standard-GitHub-Runner haben keine GPU; wenn Ihre App GPU-Rendering benötigt (z. B. DirectX), verwenden Sie
windows-latest(CPU) oder selbst gehostete Runner. Software-Rendering-Flags (z. B.QT_QUICK_BACKEND=software) können helfen. - Dienste: Wenn Ihre App einen SQL Server/Lokal-/Cloud-Dienst benötigt, starten Sie ihn in einem vorherigen Schritt. pywinauto interagiert nur mit sichtbaren Fenstern — planen Sie getrennte Tests für die Dienstlogik.
- Timeout: Erhöhen Sie
LAUNCH_TIMEOUTin CI auf 30 s (kann bei kaltem Cache länger dauern). - Bildschirmauflösung: Standard-Runner verwenden 1024×768;
Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name 'ScreenSaveActive' -Value '0'kann helfen, Bildschirmschoner zu verhindern. Verwenden Siepyautogui.size(), um die Auflösung vor dem Ausführen von Vollbildaktionen zu überprüfen.
Fehlerbehebung
| Symptom | Wahrscheinliche Ursache | Lösung |
|---|---|---|
ElementNotFoundException für AutoId | AutomationId in der App nicht gesetzt | Testbarkeit einrichten (siehe Abschnitt oben). Mit Accessibility Insights prüfen. |
set_edit_text() wirft Ausnahme | Qt 5.x / unvollständiges UIA-ValuePattern | Der Tastatur-Fallback in BasePage.type_text() wird automatisch ausgelöst. Wenn nicht, App-Code prüfen. |
| Fenstertitel wird nicht abgeglichen | Unicode-Zeichen / unsichtbare Steuerzeichen | repr(win.window_text()) drucken, um den genauen String zu sehen. Verwenden Sie Teilübereinstimmungen: window(title_re=".*Anmeldung.*"). |
| Zeitüberschreitung beim Start in CI | App stürzt ab / hängt / benötigt Admin | Prüfen Sie die Ereignisanzeige auf Abstürze. Verwenden Sie start(…), wait(„visible“) — nicht run(…). Wenn die App Admin benötigt, starten Sie den Runner als Administrator. |
| Klassennamen ändern sich zwischen Qt5/Qt6 | Qt benennt interne Klassen um | Verwenden Sie AutomationId statt Klassennamen. Wenn Sie Klassennamen verwenden müssen, prüfen Sie beide Varianten. |
Leistung: UIA-Tipps
- Geschwindigkeit: UIA ist langsamer als WinForms Backend (~5–20ms vs ~1ms), aber viel zuverlässiger. Der Kompromiss lohnt sich.
- Reduzieren Sie die Anzahl der Elemente: Wenn Ihr Fenster Tausende von UIA-Knoten hat (z. B. ein Datenraster), verwenden Sie
found_index=0+ Klassenname, um die erste Übereinstimmung zu erhalten, anstatt den gesamten Baum zu durchsuchen. - Desktop-Objekt zwischen den Tests zwischenspeichern: Wenn Sie viele Tests haben, weisen Sie `Desktop(backend=