LeadFlow CRM — Suite de Tests Selenium
automatizacion-navegador-selenium • Python 3.12 + pytest • Chrome / Firefox
Selenium 4.18 pytest 8.2 Docker Grid
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
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
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
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
🔗 Checkout
✓ 2s
⚙ Setup Python
✓ 12s
📦 pip install
✓ 28s
🏃 Smoke Tests
✓ 4m 32s
📄 Upload artifacts
✓ 5s