Testes E2E para Desktop Windows
Testes End-to-End para aplicativos nativos de desktop Windows usando pywinauto com o backend Windows UI Automation (UIA). Abrange WPF, WinForms, Win32/MFC e Qt (5.x / 6.x) — com orientações específicas para Qt em uma seção dedicada.
Quando Ativar
- Escrevendo ou executando testes E2E para um aplicativo nativo de desktop Windows
- Configurando uma suíte de testes GUI do zero
- Diagnosticando testes de automação de desktop instáveis ou com falha
- Adicionando testabilidade (AutomationId, nomes acessíveis) a um aplicativo existente
- Integrando testes E2E de desktop em um pipeline CI/CD (GitHub Actions
windows-latest)
Quando NÃO Usar
- Aplicações web → use a skill
e2e-testing(Playwright) - Aplicativos Electron / CEF / WebView2 → a camada HTML precisa de automação de navegador, não de UIA
- Aplicativos mobile → use ferramentas específicas da plataforma (UIAutomator, XCUITest)
- Testes de unidade ou integração puros que não precisam de uma GUI em execução
Conceitos Principais
Toda a automação de desktop Windows depende da UI Automation (UIA), uma API de acessibilidade integrada ao Windows. Cada framework suportado expõe uma árvore de elementos UIA com propriedades que o pywinauto pode ler e nas quais pode agir:
Seu teste (Python)
└── pywinauto (backend UIA)
└── API Windows UI Automation ← integrada ao Windows, agnóstica ao framework
└── Provedor UIA do app ← cada framework fornece o seu
└── .exe em execução
Qualidade da UIA por framework:
| Framework | AutomationId | Confiabilidade | Observações |
|---|---|---|---|
| WPF | 5/5 | Excelente | x:Name mapeia diretamente para AutomationId |
| WinForms | 4/5 | Boa | AccessibleName = AutomationId |
| UWP / WinUI 3 | 5/5 | Excelente | Suporte completo da Microsoft |
| Qt 6.x | 5/5 | Excelente | Acessibilidade habilitada por padrão; nomes de classe mudam para Qt6* |
| Qt 5.15+ | 4/5 | Boa | Módulo de Acessibilidade melhorado |
| Qt 5.7–5.14 | 3/5 | Razoável | Precisa de QT_ACCESSIBILITY=1; objectName manual |
| Win32 / MFC | 3/5 | Razoável | IDs de controle acessíveis; correspondência de texto comum |
Configuração e Pré-requisitos
# Python 3.8+, apenas Windows
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Opcional: gravação de tela
# Instale o ffmpeg e adicione ao PATH: https://ffmpeg.org/download.html
Verifique se a UIA está acessível:
from pywinauto import Desktop
Desktop(backend="uia").windows() # lista todas as janelas de nível superior
Instale o Accessibility Insights for Windows (gratuito, da Microsoft) — sua ferramenta DevTools equivalente para inspecionar a árvore de elementos UIA antes de escrever qualquer teste.
Configuração de Testabilidade (por Framework)
A ação mais impactante que você pode tomar é atribuir um AutomationId estável a cada controle interativo antes de escrever os testes.
WPF
<!-- XAML: x:Name se torna AutomationId automaticamente -->
<TextBox x:Name="usernameInput" />
<PasswordBox x:Name="passwordInput" />
<Button x:Name="btnLogin" Content="Login" />
<TextBlock x:Name="lblError" />
WinForms
// Defina no designer ou no código
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Os IDs de recurso do controle no arquivo .rc são expostos como strings AutomationId
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Prefira SetWindowText para Name; adicione IAccessible para suporte mais rico
Qt — veja a seção dedicada abaixo
Page Object Model
tests/
├── conftest.py # fixture de inicialização do app, screenshot de falha
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # necessário para imports
│ ├── base_page.py # localizadores, espera, helpers de screenshot
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # screenshots, vídeos, 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
# --- Localizadores (ordem de prioridade) ---
def by_id(self, auto_id, **kw):
"""AutomationId — mais estável. Use como primeira escolha."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Texto visível / nome acessível."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Classe de controle + índice — frágil, evite se possível."""
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):
"""Aguarda por uma nova janela de nível superior (diálogos, janelas filhas)."""
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):
"""Avalia uma condição arbitrária — use quando os eventos UIA não são confiáveis."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
if fn():
return True
except Exception:
pass
time.sleep(interval)
raise TimeoutError(f"Condição não atendida em {timeout}s")
# --- Ações ---
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: o Value Pattern da UIA pode estar incompleto
import sys, pywinauto.keyboard as kb
print(f"[windows-desktop-e2e] set_edit_text falhou ({e}), usando fallback do 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 ""
# --- Artefatos ---
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 novos projetos, prefira a fixture sandbox do Tier 1 (veja abaixo) — ela adiciona isolamento do sistema de arquivos sem custo extra. Esta fixture básica é apenas para configurações mínimas/legadas.
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Necessário para suporte UIA no 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("A variável de ambiente APP_PATH não está definida", 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 em caso de falha
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
# Saída graciosa primeiro, kill forçado como fallback
# proc é um Application do pywinauto — use wait_for_process_exit(), não 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", "") # definido via env — sem caminho padrão
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: testes smoke rápidos para caminhos críticos
flaky: testes conhecidamente instáveis
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Estratégia de Localizadores
AutomationId > Nome (texto) > ClassName + índice > XPath
(estável) (legível) (frágil) (último recurso)
Inspecione com Accessibility Insights → painel Properties → procure AutomationId primeiro.
# Inspecione em tempo de execução — cole em um REPL para explorar a árvore
win.print_control_identifiers()
# ou restrinja o escopo:
win.child_window(auto_id="groupBox1").print_control_identifiers()
Padrões de Espera
# Aguarda o controle aparecer
page.wait_visible(page.by_id("statusLabel"))
# Aguarda o controle desaparecer (ex.: spinner de carregamento)
page.wait_gone(page.by_id("spinnerOverlay"))
# Aguarda um diálogo aparecer
dlg = page.wait_window("Confirmar Exclusão")
# Condição personalizada (ex.: mudanças de texto)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Pronto")
Nunca use time.sleep() como sincronização primária — use wait() ou wait_until().
Gerenciamento de Artefatos
# Screenshot sob demanda
page.screenshot("apos_login")
# Captura de tela cheia (quando a janela está fora da tela ou minimizada)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Gravação de tela com ffmpeg (iniciar antes do teste, parar depois)
import subprocess
def iniciar_gravacao(nome):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{nome}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def parar_gravacao(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
Rastreamento por Etapa (opt-in)
O screenshot de falha padrão geralmente é muito superficial para diagnosticar testes instáveis. O rastreamento por etapa abaixo está desligado por padrão — ative-o apenas ao reproduzir um caso instável.
Ativar
E2E_TRACE=1 pytest tests/test_login.py -v
# Incluir texto digitado no log JSONL (NÃO use em testes que digitam credenciais/PII):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
Adicionar ao 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 # falha na captura não deve quebrar o teste
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)
# ... implementação existente de set_edit_text / fallback de teclado ...
self._trace("type_after", spec)
Advertências
- PII / credenciais: o conteúdo de
type_texté<redacted>por padrão. Nunca definaE2E_TRACE_INCLUDE_TEXT=1em fluxos de login ou pagamento. - Sobrecarga: ~50–200ms por ação + um PNG por etapa no disco. Não ative na matriz CI padrão — apenas em um job dedicado de reprodução de instabilidade.
- Inchaço de artefatos: um fluxo longo pode produzir dezenas de MB; ajuste o
retention-daysadequadamente. - Higiene de paralelo/re-execução: este exemplo simples anexa a
trace.jsonle usa um contador de nível de classe. Limpe o diretório de artefatos antes de reexecuções e use diretórios de artefatos por worker para testes paralelos. - Lacuna de cobertura: ações executadas fora de
BasePage(chamadas diretas aopywinautono código de teste) não são rastreadas.
Tratamento de Testes Instáveis (Flaky)
# Quarentena — equivalente a test.fixme() do Playwright
@pytest.mark.skip(reason="Instável: race de animação em CI lento. Issue #42")
def test_transicao_animada(self, app): ...
# Pular ist...
[truncado para tradução]