Tests E2E pour Bureau Windows
Tests de bout en bout pour les applications de bureau Windows natives utilisant pywinauto et l'API Windows UI Automation (UIA). Couvre WPF, WinForms, Win32/MFC et Qt (5.x / 6.x) — avec une section dédiée aux conseils spécifiques pour Qt.
Quand Activer
- Écrire ou exécuter des tests E2E pour une application de bureau Windows native
- Mettre en place une suite de tests GUI pour bureau à partir de zéro
- Diagnostiquer des tests d'automatisation de bureau intermittents ou échouant
- Ajouter de la testabilité (AutomationId, noms accessibles) à une application existante
- Intégrer les tests E2E de bureau dans un pipeline CI/CD (GitHub Actions
windows-latest)
Quand NE PAS Utiliser
- Applications web → utiliser le skill
e2e-testing(Playwright) - Applications Electron / CEF / WebView2 → la couche HTML nécessite une automatisation de navigateur, pas UIA
- Applications mobiles → utiliser des outils spécifiques à la plateforme (UIAutomator, XCUITest)
- Tests unitaires ou d'intégration purs qui n'ont pas besoin d'une interface graphique en cours d'exécution
Concepts Fondamentaux
Toute automatisation de bureau Windows repose sur UI Automation (UIA), une API d'accessibilité intégrée à Windows. Chaque framework pris en charge expose un arbre d'éléments UIA avec des propriétés que Claude peut lire et sur lesquelles agir :
Votre test (Python)
└── pywinauto (backend UIA)
└── API Windows UI Automation ← intégrée à Windows, indépendante du framework
└── Fournisseur UIA de l'application ← chaque framework a le sien
└── Fichier .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 complète de Microsoft |
| 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 ; nom d'objet manuel |
| Win32 / MFC | 3/5 | Moyenne | IDs de contrôle accessibles ; correspondance de texte courante |
Configuration & Prérequis
# Python 3.8+, Windows uniquement
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Optionnel : enregistrement d'écran
# Installer ffmpeg et l'ajouter au PATH : https://ffmpeg.org/download.html
Vérifier que UIA est accessible :
from pywinauto import Desktop
Desktop(backend="uia").windows() # liste toutes les fenêtres de premier plan
Installer Accessibility Insights for Windows (gratuit, de Microsoft) — votre équivalent DevTools pour inspecter l'arbre d'é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 AutomationId automatiquement -->
<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 IDs de ressource de contrôle dans le fichier .rc sont exposés comme des chaînes AutomationId
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Préférer SetWindowText pour le nom ; ajouter IAccessible pour un support plus riche
Qt — voir la section dédiée ci-dessous
Modèle Page Object
tests/
├── conftest.py # fixture de lancement d'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, 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
# --- 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 plan (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):
"""Interroger 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 satisfaite 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 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 de la solution de 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 la fixture de bac à sable de niveau 1 (voir ci-dessous) — elle ajoute l'isolation 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 le support 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 gracieuse 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 non-régression 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)
Inspecter avec Accessibility Insights → volet Propriétés → rechercher AutomationId en premier.
# Inspecter à l'exécution — coller dans un REPL pour explorer l'arbre
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 (ex. 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 (ex. changement 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 réduite)
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 des 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.
Activation
E2E_TRACE=1 pytest tests/test_login.py -v
# Inclure le texte saisi dans le journal JSONL (NE PAS utiliser sur les tests qui tapent des identifiants/données personnelles) :
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
Intégration 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 interrompre 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 set_edit_text / solution de repli clavier existant ...
self._trace("type_after", spec)
Mises en garde
- Données personnelles / 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 job dédié à la reproduction des tests intermittents.
- Gonflement des artefacts : un long flux peut produire 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. Nettoyez le répertoire des 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 bruts àpywinautodans le code de test) ne sont pas tracées.
Gestion des Tests Intermittents
# Quarantaine — équivalent au test.fixme() de Playwright
@pytest.mark.skip(reason="Intermittent : course d'animation sur CI lent. Problème #42")
def test_animated_transition(self, app): ...
# Ignorer si...
…
[Le texte a été tronqué pour la traduction]