¿Qué hace Pruebas E2E para Escritorio Windows?
Pruebas de extremo a extremo para aplicaciones de escritorio nativas de Windows usando pywinauto respaldado por UI Automation (UIA) de Windows. Cubre WPF, WinForms, Win32/MFC y Qt (5.x / 6.x), con orientación específica para Qt en una sección dedicada.
Cuándo activarlo
- Escribir o ejecutar pruebas E2E para una aplicación de escritorio nativa de Windows
- Configurar un suite de pruebas GUI de escritorio desde cero
- Diagnosticar pruebas de automatización de escritorio inestables o fallidas
- Agregar capacidad de prueba (AutomationId, nombres accesibles) a una aplicación existente
- Integrar pruebas E2E de escritorio en un pipeline CI/CD (GitHub Actions
windows-latest)
Cuándo NO usarlo
- Aplicaciones web → usa la habilidad
e2e-testing(Playwright) - Aplicaciones Electron / CEF / WebView2 → la capa HTML necesita automatización del navegador, no UIA
- Aplicaciones móviles → usa herramientas específicas de la plataforma (UIAutomator, XCUITest)
- Pruebas unitarias o de integración puras que no necesitan una GUI en ejecución
Conceptos centrales
Toda la automatización de escritorio de Windows se basa en UI Automation (UIA), una API de accesibilidad integrada en Windows. Cada framework compatible expone un árbol de elementos UIA con propiedades que Claude puede leer y sobre las que puede actuar:
Tu prueba (Python)
└── pywinauto (backend UIA)
└── API de UI Automation de Windows ← integrada en Windows, independiente del framework
└── Proveedor UIA de la app ← cada framework envía el suyo
└── .exe en ejecución
Calidad de UIA por framework:
| Framework | AutomationId | Confiabilidad | Notas |
|---|---|---|---|
| WPF | 5/5 | Excelente | x:Name se mapea directamente a AutomationId |
| WinForms | 4/5 | Buena | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | Excelente | Soporte completo de Microsoft |
| Qt 6.x | 5/5 | Excelente | Accesibilidad habilitada por defecto; los nombres de clase cambian a Qt6* |
| Qt 5.15+ | 4/5 | Buena | Módulo de accesibilidad mejorado |
| Qt 5.7–5.14 | 3/5 | Regular | Necesita QT_ACCESSIBILITY=1; objectName manual |
| Win32 / MFC | 3/5 | Regular | IDs de control accesibles; coincidencia de texto común |
Configuración y requisitos previos
# Python 3.8+, solo Windows
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Opcional: grabación de pantalla
# Instala ffmpeg y agrégalo al PATH: https://ffmpeg.org/download.html
Verifica que UIA sea accesible:
from pywinauto import Desktop
Desktop(backend="uia").windows() # lista todas las ventanas de nivel superior
Instala Accessibility Insights for Windows (gratuito, de Microsoft) — tu equivalente de DevTools para inspeccionar el árbol de elementos UIA antes de escribir cualquier prueba.
Configuración de capacidad de prueba (por framework)
Lo más impactante que puedes hacer es darle a cada control interactivo un AutomationId estable antes de escribir pruebas.
WPF
<!-- XAML: x:Name se convierte en AutomationId automáticamente -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Login" />
<TextBlock x:Name="lblError" />
WinForms
// Configura en el diseñador o en el código
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Los IDs de recursos de control en el archivo .rc se exponen como cadenas AutomationId
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Prefiere SetWindowText para Name; agrega IAccessible para soporte más rico
Qt — consulta la sección dedicada a continuación
Modelo de objetos de página (Page Object Model)
tests/
├── conftest.py # fixture de lanzamiento de la app, captura de pantalla en fallo
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # requerido para importaciones
│ ├── base_page.py # localizadores, espera, ayudantes de captura de pantalla
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # capturas de pantalla, videos, registros
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
# --- Localizadores (orden de prioridad) ---
def by_id(self, auto_id, **kw):
"""AutomationId — el más estable. Úsalo como primera opción."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Texto visible / nombre accesible."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Clase de control + índice — frágil, evítalo si es posible."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Esperas ---
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):
"""Espera una nueva ventana de nivel superior (diálogos, ventanas secundarias)."""
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):
"""Evalúa una condición arbitraria — úsalo cuando los eventos UIA no sean confiables."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if fn():
return True
except Exception:
pass
time.sleep(interval)
raise TimeoutError(f"La condición no se cumplió dentro de {timeout}s")
# --- Acciones ---
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: el patrón de valor UIA puede estar incompleto
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text falló ({e}), usando fallback del teclado", 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 ""
# --- Artefactos ---
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
Para proyectos nuevos, prefiere el fixture de sandbox Nivel 1 (ver más abajo) — agrega aislamiento del sistema de archivos sin costo adicional. Este fixture básico es solo para configuraciones mínimas/heredadas.
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Requerido para soporte UIA en 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 de entorno APP_PATH no está configurada", 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
# Captura de pantalla en fallo
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
# Cierre elegante primero, finalización forzada como respaldo
# proc es una aplicación de pywinauto — usa wait_for_process_exit(), no 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", "") # se configura mediante env — sin ruta predeterminada
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: pruebas rápidas de humo para rutas críticas
flaky: pruebas conocidas como inestables
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Estrategia de localizadores
AutomationId > Name (texto) > ClassName + índice > XPath
(estable) (legible) (frágil) (último recurso)
Inspecciona con Accessibility Insights → panel Propiedades → busca AutomationId primero.
# Inspecciona en tiempo de ejecución — pégalo en un REPL para explorar el árbol
win.print_control_identifiers()
# o reduce el alcance:
win.child_window(auto_id="groupBox1").print_control_identifiers()
Patrones de espera
# Espera a que aparezca un control
page.wait_visible(page.by_id("statusLabel"))
# Espera a que desaparezca un control (por ejemplo, indicador de carga)
page.wait_gone(page.by_id("spinnerOverlay"))
# Espera a que aparezca un diálogo
dlg = page.wait_window("Confirm Delete")
# Condición personalizada (por ejemplo, cambios de texto)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
Nunca uses time.sleep() como sincronización principal — usa wait() o wait_until().
Gestión de artefactos
# Captura de pantalla bajo demanda
page.screenshot("after_login")
# Captura de pantalla de pantalla completa (cuando la ventana está fuera de la pantalla o minimizada)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Grabación de pantalla con ffmpeg (inicia antes de la prueba, detén despué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)
Traza por paso (opt-in)
La captura de pantalla predeterminada en fallo a menudo es insuficiente para diagnosticar pruebas inestables. La traza a nivel de paso a continuación está desactivada por defecto — actívala solo al reproducir un caso inestable.
Habilitar
E2E_TRACE=1 pytest tests/test_login.py -v
# Incluye el texto escrito en el registro JSONL (NO lo uses en pruebas que escriban credenciales/Datos Personales Identificables):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
Integrar en 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 # el fallo de captura no debe romper la prueba
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<redactado>" 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)
# ... lógica existente de set_edit_text / fallback del teclado ...
self._trace("type_after", spec)
Advertencias
- DPI / credenciales: el contenido de
type_textestá<redactado>por defecto. Nunca establezcasE2E_TRACE_INCLUDE_TEXT=1en flujos de inicio de sesión o pagos. - Sobrecarga: ~50–200ms por acción + un PNG por paso en disco. No lo habilites en la matriz CI predeterminada, solo en un trabajo dedicado de reproducción de inestabilidad.
- Inflado de artefactos: un flujo largo produce decenas de MB; ajusta
retention-daysen consecuencia. - Higiene de paralelismo/reintentos: este ejemplo simple agrega a
trace.jsonly usa un contador a nivel de clase. Limpia el directorio de artefactos antes de reintentos, y usa directorios de artefactos por worker para pruebas paralelas. - Brecha de cobertura: las acciones realizadas fuera de
BasePage(llamadas directas apywinautoen el código de prueba) no se trazan.
Manejo de pruebas inestables
# Cuarentena — equivalente a test.fixme() de Playwright
@pytest.mark.skip(reason="Inestable: carrera de animación en CI lenta. Issue #42")
def test_animated_transition(self, app): ...