NutriTrack Pro — Suite E2E WPF · pywinauto

Aplicación de escritorio Windows para clínicas veterinarias · Nutripaw SL
Python 3.11
pywinauto · UIA backend
pytest 8 · pytest-html
GitHub Actions windows-latest
Page Object Model · Tier 1 Sandbox
12
Tests totales
10
Passed
1
Skipped
1
Failed
83%
Pass rate
📁 Estructura del proyecto de tests
📂 nutritrack-pro-e2e/
📁 nutritrack-pro-e2e/
📄 conftest.py
📄 pytest.ini
📄 config.py
📄 requirements.txt
📁 pages/
🐍 __init__.py
🐍 base_page.py
🐍 login_page.py
🐍 dashboard_page.py
🐍 paciente_page.py
🐍 plan_nutricional_page.py
📁 tests/
🐍 __init__.py
🐍 test_login.py
🐍 test_pacientes.py
🐍 test_plan_nutricional.py
📁 artifacts/
📸 screenshots/
📊 report.html
📄 .github/workflows/e2e-desktop.yml
🔧 conftest.py
Python
# conftest.py — NutriTrack Pro E2E Suite
# Fixture con Tier 1 sandbox: aislamiento de sistema de archivos por test

import os, subprocess, shlex, pytest
from pywinauto import Application
from config import APP_PATH, APP_TITLE, LAUNCH_TIMEOUT, ARTIFACT_DIR

@pytest.fixture(scope="function")
def app(request, tmp_path):
    """Proceso fresco + dirs de usuario aislados por test (Tier 1 Sandbox)."""
    if not APP_PATH:
        pytest.exit("APP_PATH no configurado", returncode=1)

    # Redirigir almacenamiento de usuario a directorio temporal aislado
    sandbox_env = os.environ.copy()
    sandbox_env["APPDATA"]      = str(tmp_path / "AppData/Roaming")
    sandbox_env["LOCALAPPDATA"] = str(tmp_path / "AppData/Local")
    sandbox_env["TEMP"] = sandbox_env["TMP"] = str(tmp_path / "Temp")
    for p in (sandbox_env["APPDATA"], sandbox_env["LOCALAPPDATA"], sandbox_env["TEMP"]):
        os.makedirs(p, exist_ok=True)

    # Lanzar app vía subprocess para pasar el entorno aislado
    proc = subprocess.Popen(
        [APP_PATH],
        env=sandbox_env,
    )
    pw_app = Application(backend="uia").connect(
        process=proc.pid, timeout=LAUNCH_TIMEOUT
    )
    win = pw_app.window(title=APP_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
    try:
        win.close()
        proc.wait(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())
🐍 pages/login_page.py
Python
from pages.base_page import BasePage

class LoginPage(BasePage):
    # --- Locators usando AutomationId (máxima estabilidad en WPF) ---
    @property
    def email(self):
        return self.by_id("emailInput")

    @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, email: str, pwd: str):
        self.type_text(self.email, email)
        self.type_text(self.password, pwd)
        self.click(self.btn_login)

    def login_ok(self, email: str, pwd: str):
        self.login(email, pwd)
        return self.wait_window("NutriTrack Pro — Dashboard")

    def get_error(self) -> str:
        self.wait_visible(self.error_label)
        return self.get_text(self.error_label)
🐍 tests/test_login.py
Python
import pytest
from pages.login_page import LoginPage

class TestLogin:

    @pytest.mark.smoke
    def test_login_credenciales_validas(self, app):
        """Smoke: login correcto redirige al dashboard."""
        page = LoginPage(app)
        dashboard = page.login_ok(
            "vet@nutritrack.es", "Secure1234!"
        )
        # Verificar que el dashboard es visible y tiene el título correcto
        assert dashboard.is_visible()
        assert "Dashboard" in dashboard.window_text()

    def test_login_password_incorrecta(self, app):
        """Login con contraseña errónea muestra mensaje de error."""
        page = LoginPage(app)
        page.login("vet@nutritrack.es", "wrong_password")
        error_msg = page.get_error()
        assert "Credenciales incorrectas" in error_msg

    def test_login_email_vacio_muestra_validacion(self, app):
        """Campo email vacío muestra error de validación inline."""
        page = LoginPage(app)
        page.login("", "Secure1234!")
        error_msg = page.get_error()
        assert "El email es obligatorio" in error_msg

    @pytest.mark.skip(reason="Flaky: animación de fade-in en CI lento. Issue #17")
    def test_login_animacion_entrada(self, app):
        """Verifica que la animación de entrada se complete antes de login."""
        page = LoginPage(app)
        page.wait_until(lambda: page.btn_login.is_enabled(), timeout=3)
        assert page.btn_login.is_enabled()
🐍 tests/test_pacientes.py
Python
import pytest
from pages.login_page import LoginPage
from pages.paciente_page import PacientePage

@pytest.fixture
def dashboard(app):
    """Fixture compuesta: lanza app y hace login antes de cada test."""
    login = LoginPage(app)
    return login.login_ok("vet@nutritrack.es", "Secure1234!")

class TestPacientes:

    @pytest.mark.smoke
    def test_alta_paciente_completo(self, dashboard):
        """Alta de nueva mascota con todos los campos rellenos."""
        page = PacientePage(dashboard)
        page.abrir_formulario()
        page.rellenar(
            nombre="Luna",
            especie="Perro",
            raza="Golden Retriever",
            peso="28.5",
            fecha_nacimiento="15/03/2020"
        )
        page.guardar()
        # Verificar que "Luna" aparece en la tabla
        assert page.paciente_en_tabla("Luna")

    def test_alta_paciente_peso_negativo_valida(self, dashboard):
        """Peso <= 0 muestra error de validación en el campo."""
        page = PacientePage(dashboard)
        page.abrir_formulario()
        page.rellenar(nombre="Max", especie="Gato", raza="Siamés", peso="-1")
        page.guardar()
        error = page.get_error_peso()
        assert "El peso debe ser mayor que 0" in error

    def test_tabla_pacientes_columnas(self, dashboard):
        """Verificar que la tabla tiene las columnas esperadas."""
        page = PacientePage(dashboard)
        cols = page.get_columnas_tabla()
        assert "Nombre" in cols
        assert "Especie" in cols
        assert "Peso (kg)" in cols

    def test_busqueda_paciente(self, dashboard):
        """Campo de búsqueda filtra pacientes por nombre."""
        page = PacientePage(dashboard)
        page.buscar("Luna")
        resultados = page.get_filas_tabla()
        assert all("Luna" in r["nombre"] for r in resultados)
🐍 tests/test_plan_nutricional.py
Python
import pytest
from pages.login_page import LoginPage
from pages.paciente_page import PacientePage
from pages.plan_nutricional_page import PlanNutricionalPage

@pytest.fixture
def paciente_seleccionado(app):
    """Login + selecciona paciente 'Luna' en tabla."""
    win = LoginPage(app).login_ok("vet@nutritrack.es", "Secure1234!")
    pac = PacientePage(win)
    pac.seleccionar_paciente("Luna")
    return win

class TestPlanNutricional:

    @pytest.mark.smoke
    @pytest.mark.parametrize("objetivo,calorias_min", [
            ("Mantenimiento",     1200),
            ("Perdida de peso",  900),
            ("Ganancia muscular",1500),
        ])
    def test_generar_plan_todos_objetivos(self, paciente_seleccionado, objetivo, calorias_min):
        """Para cada objetivo, el plan generado supera las calorías mínimas."""
        page = PlanNutricionalPage(paciente_seleccionado)
        page.clic_generar_plan()
        page.seleccionar_objetivo(objetivo)
        dialogo = page.confirmar()
        assert "Plan generado correctamente" in str(dialogo)
        cals = page.get_calorias_plan()
        assert cals >= calorias_min, f"{objetivo}: {cals} kcal < mínimo {calorias_min}"

    def test_plan_sin_paciente_seleccionado_boton_deshabilitado(self, app):
        """Sin paciente seleccionado, el botón 'Generar Plan' está deshabilitado."""
        win = LoginPage(app).login_ok("vet@nutritrack.es", "Secure1234!")
        page = PlanNutricionalPage(win)
        # Sin seleccionar ningún paciente
        btn = page.by_id("btnGenerarPlan")
        assert not btn.is_enabled(), "Botón debe estar deshabilitado sin paciente"
🔗 CI/CD Pipeline — GitHub Actions
e2e-desktop.yml · windows-latest
📥
Checkout
actions/checkout@v4
3s
🐍
Python 3.11
setup-python@v5
12s
📦
Install deps
pip install
28s
🔨
Build App
MSBuild Release
45s
🧪
Run E2E
pytest tests/
2m 18s
📤
Upload
artifacts (14d)
5s
📄 .github/workflows/e2e-desktop.yml
YAML
name: Desktop E2E — NutriTrack Pro
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: windows-latest   # GUI real, sin Xvfb
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }

      - name: Instalar dependencias
        run: pip install pywinauto pytest pytest-html Pillow pytest-timeout

      - name: Build NutriTrack Pro
        run: msbuild NutriTrackPro.sln /p:Configuration=Release /p:Platform="Any CPU"

      - name: Ejecutar suite E2E
        env:
          APP_PATH: ${{ github.workspace }}\bin\Release\NutriTrackPro.exe
          APP_TITLE: "NutriTrack Pro"
          CI: "true"
        run: |
          pytest tests/ `
            --html=artifacts/report.html --self-contained-html `
            --junitxml=artifacts/results.xml `
            --timeout=60 -v

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-nutritrack-${{ github.run_id }}
          path: artifacts/
          retention-days: 14
🏃 Resultados de la ejecución — pytest -v
🐍 tests/test_login.py
✓ 2 passed
⊝ 1 skipped
1 failed
PASS
TestLogin::test_login_credenciales_validas
smoke
3.24s
PASS
TestLogin::test_login_password_incorrecta
2.11s
FAIL
TestLogin::test_login_email_vacio_muestra_validacion
8.03s
SKIP
TestLogin::test_login_animacion_entrada
flaky #17
0.01s
FAIL — test_login_email_vacio_muestra_validacion
AssertionError: assert "El email es obligatorio" in ""  →  lblError no visible antes del timeout (8s)
Fix: el control lblError tiene AutomationId "lblValidacionEmail" (no "lblError") en esta pantalla.
Solución: usar by_id("lblValidacionEmail") en la propiedad error_label de LoginPage.
🐍 tests/test_pacientes.py
✓ 4 passed
PASS
TestPacientes::test_alta_paciente_completo
smoke
5.88s
PASS
TestPacientes::test_alta_paciente_peso_negativo_valida
4.42s
PASS
TestPacientes::test_tabla_pacientes_columnas
2.17s
PASS
TestPacientes::test_busqueda_paciente
3.31s
🐍 tests/test_plan_nutricional.py
✓ 4 passed
PASS
TestPlanNutricional::test_generar_plan_todos_objetivos[Mantenimiento-1200]
smoke
6.44s
PASS
TestPlanNutricional::test_generar_plan_todos_objetivos[Perdida de peso-900]
smoke
6.12s
PASS
TestPlanNutricional::test_generar_plan_todos_objetivos[Ganancia muscular-1500]
smoke
6.55s
PASS
TestPlanNutricional::test_plan_sin_paciente_seleccionado_boton_deshabilitado
2.89s
🛡️ Buenas prácticas aplicadas
AutomationId como localizador primario
Todos los controles WPF usan x:Name en XAML, que WPF mapea automáticamente a AutomationId. Localización 5/5 de estabilidad.
wait_visible() en lugar de time.sleep()
Cada acción espera explícitamente a que el control sea visible antes de interactuar. Elimina las esperas fijas que hacen los tests lentos y frágiles en CI.
🔒
Tier 1 Sandbox — aislamiento de filesystem por test
Cada test recibe su propio APPDATA / LOCALAPPDATA / TEMP aislado vía tmp_path. Los tests no contaminan el estado del sistema ni entre sí.
⚠️
Tests flaky cuarentenados con @pytest.mark.skip
test_login_animacion_entrada marcado como skip con referencia al issue #17. No bloquea el pipeline mientras se investiga la causa raíz.
Calidad de UIA por framework
Framework AutomationId Fiabilidad Notas
WPF ✓ (NutriTrack) 5/5 Excelente x:Name → AutomationId automático
WinForms 4/5 Buena Requiere AccessibleName manual
Qt 6.x 5/5 Excelente Accesibilidad habilitada por defecto
Qt 5.15+ 4/5 Buena Requiere QT_ACCESSIBILITY=1
Win32 / MFC 3/5 Regular Control IDs como string AutomationId