Pruebas E2E para Escritorio Windows
Pruebas integrales para aplicaciones de escritorio nativas de Windows usando pywinauto respaldado por Automatización de Interfaz de Usuario de Windows (UIA). Cubre WPF, WinForms, Win32/MFC y Qt (5.x / 6.x), con una sección dedicada a Qt.
Cuándo Activar
- Al escribir o ejecutar pruebas E2E para una aplicación de escritorio nativa de Windows
- Al configurar un conjunto de pruebas de GUI de escritorio desde cero
- Al diagnosticar pruebas de automatización de escritorio inestables o fallidas
- Al agregar capacidad de prueba (AutomationId, nombres accesibles) a una aplicación existente
- Al integrar E2E de escritorio en un pipeline CI/CD (GitHub Actions
windows-latest)
Cuándo NO Usar
- Aplicaciones web → usa la skill
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 Clave
Toda la automatización de escritorio de Windows se basa en Automatización de Interfaz de Usuario (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 Automatización de Interfaz de Usuario de Windows ← integrada en Windows, independiente del framework
└── Proveedor UIA de la aplicación ← cada framework incluye el suyo
└── .exe en ejecución
Calidad de UIA por framework:
| Framework | AutomationId | Fiabilidad | Notas |
|---|---|---|---|
| WPF | 5/5 | Excelente | x:Name se asigna 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 a 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 asignar un AutomationId estable a cada control interactivo antes de escribir las pruebas.
WPF
<!-- XAML: x:Name se convierte en AutomationId automáticamente -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Iniciar sesión" />
<TextBlock x:Name="lblError" />
WinForms
// Establecer en el diseñador o en 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 completo
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 aplicación, captura de pantalla de 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 en bucle — úsalo cuando los eventos UIA no sean fiables."""
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 alternativa: 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 alternativa de 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="Ventana Principal"):
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 sandbox de 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 caso de 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 suave primero, cierre forzado como alternativa
# proc es un Application 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 conocidamente inestables
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Estrategia de Localización
AutomationId > Nombre (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 — pega 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 (ej. indicador de carga)
page.wait_gone(page.by_id("spinnerOverlay"))
# Espera a que aparezca un diálogo
dlg = page.wait_window("Confirmar eliminación")
# Condición personalizada (ej. cambios de texto)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Listo")
Nunca uses time.sleep() como sincronización principal — usa wait() o wait_until().
Gestión de Artefactos
# Captura de pantalla bajo demanda
page.screenshot("después_del_inicio")
# Captura de pantalla completa (cuando la ventana está fuera de pantalla o minimizada)
import pyautogui
pyautogui.screenshot("artifacts/pantalla_completa.png")
# Grabación de pantalla con ffmpeg (inicia antes de la prueba, detén después)
import subprocess
def iniciar_grabacion(nombre):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{nombre}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def detener_grabacion(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
Trazado por Paso (opt-in)
La captura de pantalla de fallo predeterminada a menudo es insuficiente para diagnosticar pruebas inestables. El trazado a nivel de paso a continuación está desactivado por defecto — actívalo 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/PII):
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)
# ... set_edit_text existente / alternativa de teclado ...
self._trace("type_after", spec)
Advertencias
- PII / credenciales: el contenido de
type_textestá<redactado>por defecto. Nunca establezcasE2E_TRACE_INCLUDE_TEXT=1en flujos de inicio de sesión o pago. - 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 a la reproducción de fallos inestables.
- Inflado de artefactos: un flujo largo produce decenas de MB; ajusta
retention-daysen consecuencia. - Higiene en paralelo/reintentos: este ejemplo simple agrega a
trace.jsonly usa un contador a nivel de clase. Limpia el directorio de artefactos antes de los reintentos y usa directorios de artefactos por trabajador para pruebas paralelas. - Brecha de cobertura: las acciones realizadas fuera de
BasePage(llamadas directas apywinautoen el código de prueba) no se rastrean.
Manejo de Pruebas Inestables
# Cuarentena — equivalente a test.fixme() de Playwright
@pytest.mark.skip(reason="Inestable: carrera de animación en CI lenta. Problema #42")
def test_transición_animada(self, app): ...
# Omitir i...
Por razones de brevedad, el contenido se ha truncado en esta traducción. El markdown original se extiende más allá de este punto.