Windows Desktop E2E-Testing
End-to-End-Testing für native Windows-Desktop-Anwendungen mit pywinauto auf Basis der Windows UI Automation (UIA). Deckt WPF, WinForms, Win32/MFC und Qt (5.x / 6.x) ab – mit Qt-spezifischen Hinweisen in einem eigenen Abschnitt.
Wann aktivieren
- Schreiben oder Ausführen von E2E-Tests für eine native Windows-Desktop-Anwendung
- Einrichten einer Desktop-GUI-Testsuite von Grund auf
- Diagnose von flaky oder fehlschlagenden Desktop-Automatisierungstests
- Hinzufügen von Testbarkeit (AutomationId, zugängliche Namen) zu einer bestehenden App
- Integration von Desktop-E2E in eine CI/CD-Pipeline (GitHub Actions
windows-latest)
Wann NICHT verwenden
- Webanwendungen → verwenden Sie das Skill
e2e-testing(Playwright) - Electron / CEF / WebView2-Apps → die HTML-Ebene benötigt Browser-Automatisierung, nicht UIA
- Mobile Apps → verwenden Sie plattformspezifische Tools (UIAutomator, XCUITest)
- Reine Unit- oder Integrationstests, die keine laufende GUI benötigen
Kernkonzepte
Alle Windows-Desktop-Automatisierung basiert auf UI Automation (UIA), einer in Windows integrierten API für Barrierefreiheit. Jedes unterstützte Framework stellt einen Baum von UIA-Elementen mit Eigenschaften bereit, die Claude lesen und bearbeiten kann:
Ihr Test (Python)
└── pywinauto (UIA-Backend)
└── Windows UI Automation API ← in Windows integriert, framework-agnostisch
└── UIA-Provider der App ← jedes Framework liefert seinen eigenen
└── Ausführende .exe
UIA-Qualität nach Framework:
| Framework | AutomationId | Zuverlässigkeit | Hinweise |
|---|---|---|---|
| WPF | 5/5 | Ausgezeichnet | x:Name wird direkt auf AutomationId abgebildet |
| WinForms | 4/5 | Gut | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | Ausgezeichnet | Volle Microsoft-Unterstützung |
| Qt 6.x | 5/5 | Ausgezeichnet | 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
# Installieren Sie ffmpeg und fügen Sie es zum PATH hinzu: https://ffmpeg.org/download.html
Überprüfen Sie, ob UIA erreichbar ist:
from pywinauto import Desktop
Desktop(backend="uia").windows() # listet alle Fenster der obersten Ebene
Installieren Sie Accessibility Insights for Windows (kostenlos, von Microsoft) – Ihr DevTools-Äquivalent zur Inspektion des UIA-Elementbaums vor dem Schreiben von Tests.
Testbarkeitseinrichtung (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="Login" />
<TextBlock x:Name="lblError" />
WinForms
// Im Designer oder Code setzen
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Steuerelement-Ressourcen-IDs in der .rc-Datei werden als AutomationId-Zeichenfolgen bereitgestellt
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Bevorzugen Sie SetWindowText für den Namen; fügen Sie IAccessible für umfangreichere Unterstützung hinzu
Qt – siehe eigenen Abschnitt unten
Page-Object-Modell
tests/
├── conftest.py # App-Start-Fixture, Fehler-Screenshot
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # für Imports erforderlich
│ ├── base_page.py # Locators, Wartezeiten, Screenshot-Hilfen
│ ├── 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. Erste Wahl."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Sichtbarer Text / zugänglicher Name."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Steuerelement-Klasse + Index – fragil, wenn möglich vermeiden."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Wartevorgänge ---
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):
"""Warten auf ein neues Fenster der obersten Ebene (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):
"""Beliebige Bedingung abfragen – 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 innerhalb von {timeout}s nicht 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="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
Für neue Projekte bevorzugen Sie die Tier-1-Sandbox-Fixture (siehe unten) – sie fügt Dateisystem-Isolation ohne zusätzliche Kosten hinzu. Diese einfache Fixture ist nur für minimale/ Legacy-Setups 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 ordentliches Beenden, als Fallback erzwungenes Beenden
# proc ist eine pywinauto.Application – verwenden Sie wait_for_process_exit(), 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", "") # via env gesetzt – 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 Rauchtests für kritische Pfade
flaky: bekanntermaßen instabile Tests
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Locator-Strategie
AutomationId > Name (Text) > ClassName + Index > XPath
(stabil) (lesbar) (fragil) (letzter Ausweg)
Inspizieren Sie mit Accessibility Insights → Bereich Eigenschaften → suchen Sie zuerst nach AutomationId.
# 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
# Warten, bis ein Steuerelement erscheint
page.wait_visible(page.by_id("statusLabel"))
# Warten, bis ein Steuerelement verschwindet (z. B. Lade-Spinner)
page.wait_gone(page.by_id("spinnerOverlay"))
# Warten auf ein Dialogfenster
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 Synchronisierung – verwenden Sie wait() oder wait_until().
Artefaktverwaltung
# Screenshot auf Anfrage
page.screenshot("after_login")
# Vollbildaufnahme (wenn das Fenster ausgeblendet oder minimiert ist)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Bildschirmaufzeichnung mit ffmpeg (vor dem Test starten, nach dem Test beenden)
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-Trace (Opt-in)
Der standardmäßige Fehler-Screenshot ist oft zu dünn für die Diagnose flaky Tests. Das schrittweise Trace unten ist standardmäßig deaktiviert – aktivieren Sie es nur, wenn Sie einen flaky Fall reproduzieren.
Aktivieren
E2E_TRACE=1 pytest tests/test_login.py -v
# Eingegebenen Text in das JSONL-Log aufnehmen (NICHT für Tests verwenden, die Anmeldedaten/PII eingeben):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
In BasePage integrieren
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_textwird standardmäßig als<redacted>angezeigt. Setzen SieE2E_TRACE_INCLUDE_TEXT=1niemals bei Anmelde- oder Zahlungsabläufen. - Overhead: ~50–200ms pro Aktion + ein PNG pro Schritt auf der Festplatte. Nicht in der Standard-CI-Matrix aktivieren – nur in einem dedizierten Auftrag zur Reproduktion von Flakes.
- Artefakt-Aufblähung: Ein langer Ablauf produziert Dutzende MB; passen Sie
retention-daysentsprechend an. - Parallel-Wiederholungs-Hygiene: Dieses einfache Beispiel hängt an
trace.jsonlan und verwendet einen Klassen-zähler. Löschen Sie das Artefakt-Verzeichnis vor Wiederholungen und verwenden Sie pro-Worker-Artefaktverzeichnisse für parallele Tests. - Abdeckungslücke: Aktionen, die außerhalb von
BasePageausgeführt werden (rohepywinauto-Aufrufe im Testcode), werden nicht getrackt.
Behandlung 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 der Fehler bereits gemeldet wurde
## Qt-spezifische Testanleitung
Qt-Anwendungen können getestet werden, benötigen aber besondere Sorgfalt. Die UIA-Unterstützung variiert je nach Qt-Version und -Konfiguration erheblich.
### Qt-Versionsunterstützung
| Version | Status | Anforderungen |
|---------|--------|---------------|
| Qt 6.2+ | ✅ **Ausgezeichnet** | UIA standardmäßig aktiviert; Klassennamen sind `Qt6QListView`, `Qt6QLineEdit` usw. |
| Qt 5.15+ | ✅ **Gut** | UIA-Standardtreiber; setzen Sie `QT_ACCESSIBILITY=1` für zusätzliche Sicherheit |
| Qt 5.7–5.14 | ⚠️ **Okay** | Erfordert `QT_ACCESSIBILITY=1`, begrenztes `objectName`-Mapping |
| Qt 5.6 und älter | ❌ **Nicht empfohlen** | UIA nicht vollständig unterstützt; verwenden Sie WinForms als Alternative oder erwägen Sie ein Upgrade |
### Kritische Umgebungsvariable
```bash
# Fügen Sie dies zu Ihrem CI-Skript oder pytest.ini hinzu (wird zu pytest.giconft.py hinzugefügt)
export QT_ACCESSIBILITY=1
# Für Qt 6.2+ optional – standardmäßig aktiviert
Locators für Qt
# Standard (stabil, ein Objektname = ein Steuerelement)
page.by_id("usernameInput") # objectName: usernameInput
page.by_name("Anmelden") # Sichtbarer Text „Anmelden"
# Fallback (verwenden Sie print_control_identifiers(), um Runtime-Klassennamen zu finden)
page.by_class("Qt6QLineEdit", index=0) # Erstes Texteingabefeld
page.by_class("Qt6QPushButton", index=0) # Erste Schaltfläche
Wichtig: In Qt 6.x sind alle Klassennamen mit
Qt6(nichtQPushButton) präfigiert. Verwenden Sieprint_control_identifiers(), um die genauen Namen zur Laufzeit zu erhalten.
Qt 5.x-Text-Fallback
In Qt 5.x kann das UIA-Wertmusters (benötigt für set_edit_text()) manchmal unvollständig sein. pywinautos type_text() in base_page.py verwendet automatisch einen Tastatur-Fallback, wenn set_edit_text() fehlschlägt. Sie müssen dafür nichts weiter tun – der Code behandelt dies transparent.
Tastatur-Fallback erklärt
# Dies geschieht automatisch in type_text() – hier für Referenz:
ctrl.click_input()
kb.send_keys("^a") # Alles auswählen
text = "meinBenutzername"
kb.send_keys(text, with_spaces=True) # Text eingeben (Leerzeichen werden korrekt behandelt)
CI/CD-Integration (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: Abhängigkeiten installieren
run: |
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Optional: ffmpeg installieren
# choco install ffmpeg
- name: E2E-Tests ausführen
env:
APP_PATH: ${{ github.workspace }}\Deploy\MeineApp.exe
APP_TITLE: Hauptfenster
QT_ACCESSIBILITY: 1 # Erforderlich für Qt 5.x
run: |
pytest tests/ -v --html=artifacts/report.html --self-contained-html
- name: Artefakte hochladen
uses: actions/upload-artifact@v4
if: always()
with:
name: test-artifacts
path: artifacts/
Wichtige CI-Erwägungen
- App-Bereitstellung: Stellen Sie sicher, dass die App vor dem Start der Tests erstellt/bereitgestellt wird (z. B. mit
msbuildoder einem Installationsprogramm). - Umgebungsvariablen:
APP_PATHmuss auf die ausführbare Datei verweisen.APP_TITLEmuss dem Fenstertitel der obersten Ebene entsprechen. - CI-Timeout: Setzen Sie ein angemessenes pytest-Timeout (
pytest-timeout), um zu verhindern, dass hängende Tests die gesamte CI-Pipeline blockieren. - Artefakt-Lebensdauer: Konfigurieren Sie
retention-daysim Schrittupload-artifact, um die Speichernutzung zu verwalten. - Bildschirmauflösung: Der standardmäßige
windows-latest-Runner verwendet die Auflösung 1024x768. Wenn Ihre App mehr benötigt, konfigurieren Sie sie vor dem Start des Testläufers.
Zugänglichkeitsprüfung mit Accessibility Insights
Accessibility Insights for Windows ist unverzichtbar für die Erstellung zuverlässiger Tests:
- Starten Sie die App unter Test.
- Öffnen Sie Accessibility Insights → Live-Inspect.
- Zeigen Sie auf ein Steuerelement → der rechte Bereich zeigt alle UIA-Eigenschaften an.
- Suchen Sie nach
AutomationId– wenn vorhanden, ist dies Ihr stabilster Locator. - Notieren Sie sich den AutomationId-Wert und verwenden Sie ihn in
by_id().
Pro-Tipp: Wenn
AutomationIdleer ist, suchen Sie nachName(Text) oderClassName– diese sind weniger stabil, aber als Fallback akzeptabel.
Verbesserung der Testbarkeit
Wenn die App kein AutomationId bereitstellt, haben Sie zwei Möglichkeiten:
1. App-Code ändern (bevorzugt)
Fügen Sie AutomationId für jedes interaktive Steuerelement in der App hinzu:
- WPF:
x:Namewird automatisch zur AutomationId. - WinForms: Setzen Sie
AccessibleName. - Win32/MFC: Setzen Sie
IAccessibleund legen SieNamefest. - Qt: Setzen Sie
setObjectName().
2. UIA überbrücken (falls keine Codeänderung möglich)
# Verwenden Sie print_control_identifiers(), um Runtime-ID-Muster zu finden
win.print_control_identifiers()
# Suchen Sie nach konsistenten Klassennamen oder Textmustern
page.by_name("OK").click() # Verwenden Sie sichtbaren Text, wenn AutomationId nicht verfügbar ist
Fehlerbehebung
Häufige Probleme
| Problem | Ursache | Lösung |
|---|---|---|
ElementNotEnabledException | Steuerelement deaktiviert/anfangs ausgeblendet | Warten Sie auf Sichtbarkeit, bevor Sie Maßnahmen ergreifen |
ElementNotVisibleException | Steuerelement nicht im Ansichtsfenster oder überlagert | Scrollen oder verwenden Sie click_input() (simuliert Mausbewegung) |
NoSuchWindowException | Fenster wurde nicht gefunden oder geschlossen | Überprüfen Sie MAIN_WINDOW_TITLE; verwenden Sie wait_window() |
TimeoutError | Steuerelement erscheint nicht innerhalb des Timeouts | Erhöhen Sie ACTION_TIMEOUT oder prüfen Sie auf Ausnahmen/UI-Änderungen |
| Flaky-Tests | Zeitprobleme, UIA-Eigenschaften ändern sich zwischen Läufen | Verwenden Sie wait_until() und Accessibility Insights, um die Locator-Quelle zu überprüfen |
Protokollierung
# Ausführliche pywinauto-Protokollierung aktivieren
import logging
logging.basicConfig(level=logging.DEBUG)
# Odere auf pywinauto beschränken:
logging.getLogger("pywinauto").setLevel(logging.DEBUG)
Best Practices-Zusammenfassung
- Priorisieren Sie AutomationId – es ist der stabilste Locator.
- Vermeiden Sie
time.sleep()– verwenden Siewait_visible(),wait_gone()oderwait_until(). - Stellen Sie Bereinigung bei Fehlern sicher – die Fixture in
conftest.pymacht automatisch einen Screenshot und beendet die App sauber. - Isolieren Sie die App – verwenden Sie Sandbox-Umgebungen (Tier 1) für Dateisystem-Isolation, wann immer möglich.
- Überprüfen Sie vor dem Testen – verwenden Sie
Accessibility Insights, um Locators zu überprüfen, bevor Sie Code schreiben. - Setzen Sie Timeouts – setzen Sie
ACTION_TIMEOUTundLAUNCH_TIMEOUTfür jede Testsuite. - Gruppieren Sie Artefakte – Screenshots und Videoaufnahmen helfen bei der Diagnose von Fehlern in CI.
- Qt-spezifische Aufmerksamkeit – setzen Sie
QT_ACCESSIBILITY=1und überprüfen Sie die UIA-Eigenschaften mit Accessibility Insights, bevor Sie Tests schreiben.