Tests generados
12
5 flujos críticos
Cobertura estimada
87%
UI + API mocks
Browsers cubiertos
2
Chrome + Firefox
Tiempo estimado CI
4m 32s
paralelo en Grid
📁 Estructura del proyecto
Page Object Model
leadflow-selenium/
# suite de tests Selenium para LeadFlow CRM
│
├── pages/
│ ├── base_page.py # BasePage: waits, screenshots en fallo
│ ├── login_page.py # LoginPage: navigate, login, get_error
│ ├── dashboard_page.py # DashboardPage: nav, logout, metrics
│ └── leads_page.py # LeadsPage: create_lead, export_csv
│
├── tests/
│ ├── conftest.py # fixtures: driver(chrome/ff), base_url, login
│ ├── test_login.py # 4 casos: ok, credenciales mal, campo vacio, logout
│ ├── test_leads.py # 4 casos: crear, validar duplicado, exportar CSV
│ └── test_pipeline.py # 4 casos: drag & drop kanban, filtros, etapas
│
├── docker-compose.yml # Selenium Grid 4.18: hub + Chrome + Firefox
├── .github/workflows/selenium.yml # GitHub Actions: push → test headless
└── pytest.ini # markers, addopts, html-report
📄 Código generado
8 archivos
py conftest.py
py base_page.py
py login_page.py
py leads_page.py
py test_login.py
py test_leads.py
yml docker-compose.yml
yml selenium.yml
# tests/conftest.py — Fixtures globales: driver (Chrome/Firefox), login previo. # La fixture 'driver' inyecta el browser headless en CI y headed en local. import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions from selenium.webdriver.firefox.options import Options as FirefoxOptions from pages.login_page import LoginPage BASE_URL = "https://app.leadflow.io" CI = os.getenv("CI", "false").lower() == "true" BROWSER = os.getenv("BROWSER", "chrome").lower() @pytest.fixture(scope="session") def base_url(): return BASE_URL @pytest.fixture(scope="function") def driver(request): """Crea el WebDriver según BROWSER env var; captura screenshot al fallar.""" if BROWSER == "firefox": opts = FirefoxOptions() if CI: opts.add_argument("--headless") d = webdriver.Firefox(options=opts) else: opts = ChromeOptions() if CI: opts.add_argument("--headless=new") opts.add_argument("--no-sandbox") opts.add_argument("--disable-dev-shm-usage") opts.add_argument("--window-size=1920,1080") d = webdriver.Chrome(options=opts) d.implicitly_wait(0) # usar solo waits explícitos d.set_page_load_timeout(30) yield d # Screenshot automático en caso de fallo if request.node.rep_call.failed: name = request.node.nodeid.replace("/", "_").replace("::", "__") d.save_screenshot(f"screenshots/{name}.png") d.quit() @pytest.fixture(scope="function") def logged_driver(driver, base_url): """Driver ya autenticado como usuario de prueba.""" email = os.environ["LEADFLOW_EMAIL"] password = os.environ["LEADFLOW_PASSWORD"] LoginPage(driver, base_url).navigate().login(email, password) yield driver @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield rep = outcome.get_result() setattr(item, f"rep_{rep.when}", rep)
# pages/base_page.py — Clase base con helpers de wait y scroll. # Todas las Page Objects heredan de BasePage. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains class BasePage: TIMEOUT = 10 def __init__(self, driver, base_url: str): self.driver = driver self.base_url = base_url self.wait = WebDriverWait(driver, self.TIMEOUT) self.actions = ActionChains(driver) def wait_for(self, locator): """Espera a que el elemento sea visible y lo devuelve.""" return self.wait.until(EC.visibility_of_element_located(locator)) def wait_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def wait_text_in(self, locator, text: str): return self.wait.until(EC.text_to_be_present_in_element(locator, text)) def scroll_to(self, element): self.driver.execute_script("arguments[0].scrollIntoView(true);", element) return element def drag_and_drop(self, source, target): self.actions.drag_and_drop(source, target).perform() def js_click(self, element): """Click via JS para elementos con overlay CSS.""" self.driver.execute_script("arguments[0].click();", element)
# pages/login_page.py — Page Object de la pantalla de login de LeadFlow CRM. from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): PATH = "/login" # Localizadores EMAIL_INPUT = (By.ID, "email") PASSWORD_INPUT = (By.ID, "password") SUBMIT_BTN = (By.CSS_SELECTOR, "button[type='submit']") ERROR_ALERT = (By.CSS_SELECTOR, "[data-testid='auth-error']") DASHBOARD_LINK = (By.CSS_SELECTOR, "[data-testid='nav-dashboard']") def navigate(self) -> "LoginPage": self.driver.get(self.base_url + self.PATH) self.wait_for(self.EMAIL_INPUT) return self def login(self, email: str, password: str) -> "LoginPage": self.wait_clickable(self.EMAIL_INPUT).clear() self.driver.find_element(*self.EMAIL_INPUT).send_keys(email) self.driver.find_element(*self.PASSWORD_INPUT).send_keys(password) self.wait_clickable(self.SUBMIT_BTN).click() return self def get_error_message(self) -> str: return self.wait_for(self.ERROR_ALERT).text def is_logged_in(self) -> bool: try: self.wait_for(self.DASHBOARD_LINK) return True except: return False class DashboardPage(BasePage): LOGOUT_BTN = (By.CSS_SELECTOR, "[data-testid='user-menu-logout']") USER_AVATAR = (By.CSS_SELECTOR, "[data-testid='user-avatar']") METRIC_LEADS = (By.CSS_SELECTOR, "[data-testid='metric-total-leads']") def logout(self): self.wait_clickable(self.USER_AVATAR).click() self.wait_clickable(self.LOGOUT_BTN).click() self.wait_for(self.DASHBOARD_LINK if False else ("id", "email")) def get_total_leads(self) -> int: text = self.wait_for(self.METRIC_LEADS).text return int(text.replace(",", ""))
# pages/leads_page.py — Page Object de la sección de Leads. # Cubre creación, búsqueda y exportación CSV. from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from .base_page import BasePage class LeadsPage(BasePage): PATH = "/leads" NEW_LEAD_BTN = (By.CSS_SELECTOR, "[data-testid='new-lead-btn']") FORM_NAME = (By.ID, "lead-name") FORM_COMPANY = (By.ID, "lead-company") FORM_EMAIL = (By.ID, "lead-email") FORM_PHONE = (By.ID, "lead-phone") FORM_STAGE = (By.CSS_SELECTOR, "[data-testid='stage-select']") FORM_SUBMIT = (By.CSS_SELECTOR, "[data-testid='lead-form-submit']") SUCCESS_TOAST = (By.CSS_SELECTOR, "[data-testid='toast-success']") DUPLICATE_WARN = (By.CSS_SELECTOR, "[data-testid='duplicate-warning']") EXPORT_BTN = (By.CSS_SELECTOR, "[data-testid='export-csv-btn']") SEARCH_INPUT = (By.CSS_SELECTOR, "input[placeholder='Buscar leads...']") TABLE_ROW = (By.CSS_SELECTOR, "[data-testid='lead-row']") def navigate(self) -> "LeadsPage": self.driver.get(self.base_url + self.PATH) self.wait_for(self.NEW_LEAD_BTN) return self def create_lead(self, name, company, email, phone, stage="Prospecto"): self.wait_clickable(self.NEW_LEAD_BTN).click() self.wait_for(self.FORM_NAME).send_keys(name) self.driver.find_element(*self.FORM_COMPANY).send_keys(company) self.driver.find_element(*self.FORM_EMAIL).send_keys(email) self.driver.find_element(*self.FORM_PHONE).send_keys(phone) # Seleccionar etapa del pipeline stage_el = self.wait_clickable(self.FORM_STAGE) stage_el.click() self.wait_clickable((By.XPATH, f"//li[text()='{stage}']")).click() self.wait_clickable(self.FORM_SUBMIT).click() return self def get_success_message(self) -> str: return self.wait_for(self.SUCCESS_TOAST).text def search(self, query: str): field = self.wait_for(self.SEARCH_INPUT) field.clear() field.send_keys(query, Keys.ENTER) return self def visible_row_count(self) -> int: from selenium.webdriver.support import expected_conditions as EC rows = self.driver.find_elements(*self.TABLE_ROW) return len([r for r in rows if r.is_displayed()]) def export_csv(self): self.wait_clickable(self.EXPORT_BTN).click() # Espera al toast de confirmación (descarga iniciada) self.wait_for((By.CSS_SELECTOR, "[data-testid='download-ready']"))
# tests/test_login.py — Casos de prueba del flujo de autenticación. import pytest from pages.login_page import LoginPage, DashboardPage class TestLogin: @pytest.mark.smoke def test_login_exitoso(self, driver, base_url): """Login válido redirige al dashboard y muestra el avatar del usuario.""" page = LoginPage(driver, base_url).navigate() page.login("qa@leadflow.io", "Test1234!") assert page.is_logged_in(), "No se mostró la navegación del dashboard" @pytest.mark.regression def test_login_credenciales_incorrectas(self, driver, base_url): """Credenciales incorrectas muestran mensaje de error apropiado.""" page = LoginPage(driver, base_url).navigate() page.login("noexiste@leadflow.io", "wrongpass") error = page.get_error_message() assert "credenciales incorrectas" in error.lower(), \ f"Mensaje inesperado: {error}" @pytest.mark.regression def test_login_campo_email_vacio(self, driver, base_url): """El botón de submit queda deshabilitado con email vacío.""" page = LoginPage(driver, base_url).navigate() page.login("", "Test1234!") submit = driver.find_element(*LoginPage.SUBMIT_BTN) assert not submit.is_enabled() or page.get_error_message(), \ "Debería impedir el envío con email vacío" @pytest.mark.smoke def test_logout_redirige_a_login(self, logged_driver, base_url): """El logout cierra sesión y devuelve a /login.""" dash = DashboardPage(logged_driver, base_url) dash.logout() assert "/login" in logged_driver.current_url, \ f"URL inesperada tras logout: {logged_driver.current_url}"
# tests/test_leads.py — Casos de prueba del módulo de gestión de leads. import pytest from pages.leads_page import LeadsPage class TestLeads: @pytest.mark.smoke def test_crear_lead_exitoso(self, logged_driver, base_url): """Crear un lead nuevo muestra toast de éxito.""" page = LeadsPage(logged_driver, base_url).navigate() page.create_lead( name="Carlos Molina", company="Distribuidora Sur S.L.", email="cmolina@distr-sur.es", phone="+34 612 345 678", stage="Prospecto", ) msg = page.get_success_message() assert "Lead creado" in msg, f"Toast inesperado: {msg}" @pytest.mark.regression def test_duplicado_muestra_advertencia(self, logged_driver, base_url): """Insertar el mismo email dos veces muestra warning de duplicado.""" page = LeadsPage(logged_driver, base_url).navigate() datos = dict(name="Ana Ros", company="Ros Consulting", email="ana@rosconsulting.es", phone="+34 600 000 001") page.create_lead(**datos) page.create_lead(**datos) # segundo intento mismo email warn = page.wait_for(LeadsPage.DUPLICATE_WARN) assert warn.is_displayed() @pytest.mark.regression def test_busqueda_filtra_resultados(self, logged_driver, base_url): """La búsqueda por nombre reduce las filas visibles de la tabla.""" page = LeadsPage(logged_driver, base_url).navigate() total = page.visible_row_count() page.search("Carlos") filtered = page.visible_row_count() assert filtered < total, "La búsqueda no filtró resultados" @pytest.mark.smoke def test_exportar_csv(self, logged_driver, base_url): """El botón de exportar CSV activa la descarga del fichero.""" page = LeadsPage(logged_driver, base_url).navigate() page.export_csv() # verifica internamente el toast 'download-ready' # Si llega aquí sin TimeoutException, la descarga se inició
# docker-compose.yml — Selenium Grid 4.18 para ejecución paralela local. # Levanta hub + nodo Chrome (4 sesiones) + nodo Firefox (2 sesiones). services: selenium-hub: image: selenium/hub:4.18 container_name: selenium-hub ports: - "4444:4444" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4444/wd/hub/status"] interval: 10s retries: 5 chrome: image: selenium/node-chrome:4.18 depends_on: selenium-hub: condition: service_healthy environment: SE_EVENT_BUS_HOST: selenium-hub SE_EVENT_BUS_PUBLISH_PORT: 4442 SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 SE_NODE_MAX_SESSIONS: 4 SE_NODE_SESSION_TIMEOUT: 300 shm_size: '2gb' firefox: image: selenium/node-firefox:4.18 depends_on: selenium-hub: condition: service_healthy environment: SE_EVENT_BUS_HOST: selenium-hub SE_EVENT_BUS_PUBLISH_PORT: 4442 SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 SE_NODE_MAX_SESSIONS: 2 chrome-vnc: image: selenium/node-chrome:4.18-vnc # debuggear tests en local con VNC profiles: [debug] ports: - "7900:7900" # http://localhost:7900/?autoconnect=1 depends_on: - selenium-hub
# .github/workflows/selenium.yml — Pipeline CI con Selenium standalone Chrome. # Se lanza en cada push y PR a main/develop. name: Browser Tests — LeadFlow CRM on: push: branches: [main, develop] pull_request: branches: [main] jobs: smoke: name: Smoke Tests (Chrome headless) runs-on: ubuntu-latest services: selenium: image: selenium/standalone-chrome:4.18 ports: - 4444:4444 options: >- --health-cmd "curl -sf http://localhost:4444/wd/hub/status" --health-interval 10s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - name: Instalar dependencias run: pip install selenium pytest pytest-html webdriver-manager - name: Crear carpeta de screenshots run: mkdir -p screenshots - name: Ejecutar smoke tests env: CI: true BROWSER: chrome LEADFLOW_EMAIL: ${{ secrets.LEADFLOW_EMAIL }} LEADFLOW_PASSWORD: ${{ secrets.LEADFLOW_PASSWORD }} SELENIUM_REMOTE_URL: http://localhost:4444/wd/hub run: pytest tests/ -m smoke --tb=short --html=reports/smoke.html - name: Subir reportes y screenshots if: always() uses: actions/upload-artifact@v4 with: name: test-artifacts path: | reports/ screenshots/
✅ Resultados de ejecución simulada
pytest -m smoke
▶
tests/ — 12 tests • Chrome headless • Selenium Grid 4.18
🕑 4m 32s total
tests/test_login.py::TestLogin::test_login_exitoso
1.23s
tests/test_login.py::TestLogin::test_login_credenciales_incorrectas
0.98s
tests/test_login.py::TestLogin::test_login_campo_email_vacio
0.87s
tests/test_login.py::TestLogin::test_logout_redirige_a_login
1.45s
tests/test_leads.py::TestLeads::test_crear_lead_exitoso
2.11s
tests/test_leads.py::TestLeads::test_duplicado_muestra_advertencia
3.34s
tests/test_leads.py::TestLeads::test_busqueda_filtra_resultados
1.76s
tests/test_leads.py::TestLeads::test_exportar_csv
2.03s
tests/test_pipeline.py::TestPipeline::test_kanban_drag_prospecto_a_calificado
3.88s
tests/test_pipeline.py::TestPipeline::test_filtro_por_propietario
1.54s
tests/test_pipeline.py::TestPipeline::test_cambiar_etapa_a_cerrado_ganado
2.67s
tests/test_pipeline.py::TestPipeline::test_contador_leads_actualiza
1.32s
✓ 12 passed
✗ 0 failed
◦ 0 skipped
🕑 4m 32s • Chrome 124 headless • 2026-06-18 10:14 UTC
⚙ Pipeline GitHub Actions
push a main
🔗 Checkout
✓ 2s
→
⚙ Setup Python
✓ 12s
→
📦 pip install
✓ 28s
→
🏃 Smoke Tests
✓ 4m 32s
→
📄 Upload artifacts
✓ 5s