Tests E2E Windows Desktop
Tests de bout en bout pour les applications de bureau Windows natives utilisant pywinauto avec le backend Windows UI Automation (UIA). Couvre WPF, WinForms, Win32/MFC et Qt (5.x / 6.x) — avec une section dédiée à Qt.
Quand activer
- Rédaction ou exécution de tests E2E pour une application de bureau Windows native
- Mise en place d'une suite de tests d'interface graphique de bureau à partir de zéro
- Diagnostic de tests d'automatisation de bureau intermittents ou échoués
- Ajout de testabilité (AutomationId, noms accessibles) à une application existante
- Intégration de tests E2E de bureau dans un pipeline CI/CD (GitHub Actions
windows-latest)
Quand NE PAS utiliser
- Applications Web → utiliser la compétence
e2e-testing(Playwright) - Applications Electron / CEF / WebView2 → la couche HTML nécessite l'automatisation du navigateur, pas UIA
- Applications mobiles → utiliser les outils spécifiques à la plateforme (UIAutomator, XCUITest)
- Tests unitaires ou d'intégration purs qui ne nécessitent pas d'interface graphique en cours d'exécution
Concepts de base
Toute l'automatisation de bureau Windows repose sur UI Automation (UIA), une API d'accessibilité intégrée à Windows. Chaque framework pris en charge expose une arborescence d'éléments UIA avec des propriétés que Claude peut lire et sur lesquelles il peut agir :
Votre test (Python)
└── pywinauto (backend UIA)
└── API Windows UI Automation ← intégré à Windows, indépendant du framework
└── Fournisseur UIA de l'application ← chaque framework fournit le sien
└── .exe en cours d'exécution
Qualité UIA par framework :
| Framework | AutomationId | Fiabilité | Remarques |
|---|---|---|---|
| WPF | 5/5 | Excellente | x:Name correspond directement à AutomationId |
| WinForms | 4/5 | Bonne | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | Excellente | Prise en charge Microsoft complète |
| Qt 6.x | 5/5 | Excellente | Accessibilité activée par défaut ; les noms de classe deviennent Qt6* |
| Qt 5.15+ | 4/5 | Bonne | Module d'accessibilité amélioré |
| Qt 5.7–5.14 | 3/5 | Moyenne | Nécessite QT_ACCESSIBILITY=1 ; objectName manuel |
| Win32 / MFC | 3/5 | Moyenne | ID de contrôle accessibles ; correspondance de texte courante |
Installation et prérequis
# Python 3.8+, Windows uniquement
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Facultatif : enregistrement d'écran
# Installer ffmpeg et l'ajouter au PATH : https://ffmpeg.org/download.html
Vérifiez que UIA est accessible :
from pywinauto import Desktop
Desktop(backend="uia").windows() # liste toutes les fenêtres de premier niveau
Installez Accessibility Insights for Windows (gratuit, de Microsoft) — votre équivalent DevTools pour inspecter l'arborescence des éléments UIA avant d'écrire un test.
Configuration de la testabilité (par framework)
La chose la plus importante que vous puissiez faire est de donner à chaque contrôle interactif un AutomationId stable avant d'écrire des tests.
WPF
<!-- XAML : x:Name devient automatiquement AutomationId -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Connexion" />
<TextBlock x:Name="lblError" />
WinForms
// Définir dans le designer ou le code
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Les ID de ressource de contrôle dans le fichier .rc sont exposés sous forme de chaînes AutomationId
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Préférez SetWindowText pour le nom ; ajoutez IAccessible pour une prise en charge plus riche
Qt — voir la section dédiée ci-dessous
Modèle Page Object
tests/
├── conftest.py # fixture de lancement de l'application, capture d'écran en cas d'échec
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # requis pour les imports
│ ├── base_page.py # localisateurs, attente, aides à la capture d'écran
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # captures d'écran, vidéos, journaux
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
# --- Localisateurs (ordre de priorité) ---
def by_id(self, auto_id, **kw):
"""AutomationId — le plus stable. À utiliser en premier choix."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Texte visible / nom accessible."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Classe de contrôle + index — fragile, à éviter si possible."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Attentes ---
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):
"""Attendre une nouvelle fenêtre de premier niveau (boîtes de dialogue, fenêtres enfants)."""
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):
"""Sonder une condition arbitraire — à utiliser lorsque les événements UIA ne sont pas fiables."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if fn():
return True
except Exception:
pass
time.sleep(interval)
raise TimeoutError(f"Condition non remplie dans les {timeout}s")
# --- Actions ---
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:
# Solution de repli pour Qt 5.x : le modèle de valeur UIA peut être incomplet
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text a échoué ({e}), utilisation du repli clavier", 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 ""
# --- Artefacts ---
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="Fenêtre principale"):
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
Pour les nouveaux projets, préférez l'utilisation de la fixture sandbox de niveau 1 (voir ci-dessous) — elle ajoute un isolement du système de fichiers sans coût supplémentaire. Cette fixture de base est réservée aux configurations minimales/héritées.
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Requis pour la prise en charge UIA de Qt 5.x
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("La variable d'environnement APP_PATH n'est pas définie", 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
# Capture d'écran en cas d'échec
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
# Fermeture en douceur d'abord, arrêt forcé en dernier recours
# proc est une Application pywinauto — utiliser wait_for_process_exit(), pas 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", "") # défini via env — pas de chemin par défaut
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: tests de fumée rapides pour les chemins critiques
flaky: tests connus pour être instables
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Stratégie de localisation
AutomationId > Nom (texte) > ClassName + index > XPath
(stable) (lisible) (fragile) (dernier recours)
Inspectez avec Accessibility Insights → volet Propriétés → recherchez d'abord AutomationId.
# Inspecter au moment de l'exécution — coller dans un REPL pour explorer l'arborescence
win.print_control_identifiers()
# ou réduire la portée :
win.child_window(auto_id="groupBox1").print_control_identifiers()
Modèles d'attente
# Attendre l'apparition d'un contrôle
page.wait_visible(page.by_id("statusLabel"))
# Attendre la disparition d'un contrôle (par exemple, indicateur de chargement)
page.wait_gone(page.by_id("spinnerOverlay"))
# Attendre l'ouverture d'une boîte de dialogue
dlg = page.wait_window("Confirmer la suppression")
# Condition personnalisée (par exemple, changements de texte)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Prêt")
N'utilisez jamais time.sleep() comme synchronisation principale — utilisez wait() ou wait_until().
Gestion des artefacts
# Capture d'écran à la demande
page.screenshot("after_login")
# Capture plein écran (lorsque la fenêtre est hors écran ou minimisée)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Enregistrement d'écran avec ffmpeg (démarrer avant le test, arrêter après)
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)
Trace par étape (opt-in)
La capture d'écran par défaut en cas d'échec est souvent insuffisante pour diagnostiquer les tests intermittents. La trace au niveau de l'étape ci-dessous est désactivée par défaut — activez-la uniquement lors de la reproduction d'un cas intermittent.
Activer
E2E_TRACE=1 pytest tests/test_login.py -v
# Inclure le texte saisi dans le journal JSONL (NE PAS utiliser sur les tests qui saisissent des identifiants/PII) :
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
Appliquer le patch dans 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 # l'échec de la capture ne doit pas casser le test
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<masqué>" 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)
# ... code existant set_edit_text / repli clavier ...
self._trace("type_after", spec)
Mises en garde
- PII / identifiants : le contenu de
type_textest<masqué>par défaut. Ne définissez jamaisE2E_TRACE_INCLUDE_TEXT=1sur les flux de connexion ou de paiement. - Surcharge : ~50–200 ms par action + un PNG par étape sur le disque. N'activez pas sur la matrice CI par défaut — uniquement sur un travail dédié de reproduction des échecs intermittents.
- Gonflement des artefacts : un flux long produit des dizaines de Mo ; ajustez
retention-daysen conséquence. - Hygiène parallèle/réexécution : cet exemple simple ajoute à
trace.jsonlet utilise un compteur au niveau de la classe. Videz le répertoire d'artefacts avant les réexécutions et utilisez des répertoires d'artefacts par worker pour les tests parallèles. - Lacune de couverture : les actions effectuées en dehors de
BasePage(appels brutspywinautodans le code de test) ne sont pas tracées.
Gestion des tests intermittents
# Quarantaine — équivalent à test.fixme() de Playwright
@pytest.mark.skip(reason="Intermittent : course d'animation sur CI lent. Problème #42")
def test_animated_transition(self, app): ...
# Sauter une ressource insuffisante
... (contenu tronqué)
[Le contenu complet a été tronqué pour la traduction.]