El patrón Page Object Model en Python
flowchart TB
Tests["tests/test_*.py pytest"] --> Pages["pages/"]
Pages --> BP[base_page.py BasePage class]
Pages --> LP[login_page.py LoginPage]
Pages --> HP[home_page.py HomePage]
Pages --> CP[cart_page.py CartPage]
BP --> Driver["self.driver: WebDriver"]
BP --> Wait["self.wait: WebDriverWait"]
BP --> Find[wait_until_visible locator]
LP --> Loc["Locators tuples by, value"]
LP --> Met["Métodos: login, fill_user"]
Met --> Ret[Return HomePage instance]
Tests --> Conf[conftest.py fixture driver]
Conf --> Yield["yield driver / driver.quit"]
El Page Object Model (POM) es el patrón de diseño más utilizado en la automatización web. En Python, su implementación es especialmente elegante gracias al tipado con typing y la claridad de la sintaxis.
La idea central es encapsular en clases los localizadores y las interacciones de cada página, separando la lógica de los tests de los detalles técnicos de la interfaz.
Sin POM:
# Frágil: localizadores esparcidos por todo el código
def test_login():
driver.find_element(By.ID, "email").send_keys("user@test.com")
driver.find_element(By.ID, "password").send_keys("pass123")
driver.find_element(By.CSS_SELECTOR, ".btn-login").click()
assert "/dashboard" in driver.current_url
Con POM:
# Limpio: la lógica de UI está en LoginPage
def test_login(driver):
login = LoginPage(driver)
dashboard = login.login_como("user@test.com", "pass123")
assert dashboard.esta_cargada()
BasePage: clase base con utilidades comunes
Crear una BasePage con métodos de espera y utilidades comunes evita duplicar código en cada Page Object.
# pages/base_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
class BasePage:
"""Clase base con métodos de utilidad para todos los Page Objects."""
TIMEOUT = 10 # Tiempo máximo de espera en segundos
def __init__(self, driver: WebDriver):
self.driver = driver
self.espera = WebDriverWait(driver, self.TIMEOUT)
def ir_a(self, url: str) -> None:
"""Navega a la URL especificada."""
self.driver.get(url)
def esperar_visible(self, localizador: tuple) -> WebElement:
"""Espera hasta que el elemento sea visible y lo devuelve."""
return self.espera.until(
EC.visibility_of_element_located(localizador)
)
def esperar_clickable(self, localizador: tuple) -> WebElement:
"""Espera hasta que el elemento sea clickable y lo devuelve."""
return self.espera.until(
EC.element_to_be_clickable(localizador)
)
def esperar_url_contiene(self, texto: str) -> bool:
"""Espera hasta que la URL contenga el texto especificado."""
return self.espera.until(EC.url_contains(texto))
def obtener_titulo(self) -> str:
"""Devuelve el título de la página actual."""
return self.driver.title
def obtener_url_actual(self) -> str:
"""Devuelve la URL de la página actual."""
return self.driver.current_url
def tomar_captura(self, nombre_archivo: str) -> None:
"""Toma una captura de pantalla."""
self.driver.save_screenshot(nombre_archivo)
def esta_en_pagina(self, url_esperada: str) -> bool:
"""Verifica si la URL actual contiene la esperada."""
return url_esperada in self.driver.current_url
Page Objects concretos
# pages/login_page.py
from __future__ import annotations
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class LoginPage(BasePage):
"""Page Object para la página de inicio de sesión."""
URL = "https://www.ejemplo.com/login"
# Localizadores (privados por convención)
_EMAIL_INPUT = (By.ID, "email")
_PASSWORD_INPUT = (By.ID, "password")
_LOGIN_BUTTON = (By.CSS_SELECTOR, "[data-testid='btn-login']")
_ERROR_MESSAGE = (By.CLASS_NAME, "error-message")
_FORGOT_PASSWORD_LINK = (By.LINK_TEXT, "¿Olvidaste tu contraseña?")
def abrir(self) -> LoginPage:
"""Navega a la página de login."""
self.ir_a(self.URL)
return self
def introducir_email(self, email: str) -> LoginPage:
"""Escribe el email en el campo correspondiente."""
campo = self.esperar_clickable(self._EMAIL_INPUT)
campo.clear()
campo.send_keys(email)
return self
def introducir_password(self, password: str) -> LoginPage:
"""Escribe la contraseña en el campo correspondiente."""
campo = self.esperar_clickable(self._PASSWORD_INPUT)
campo.clear()
campo.send_keys(password)
return self
def hacer_clic_login(self) -> DashboardPage:
"""Hace clic en el botón de login y navega al dashboard."""
from pages.dashboard_page import DashboardPage
self.esperar_clickable(self._LOGIN_BUTTON).click()
return DashboardPage(self.driver)
def login_como(self, email: str, password: str) -> DashboardPage:
"""Método conveniente: hace el login completo en un paso."""
return (
self.introducir_email(email)
.introducir_password(password)
.hacer_clic_login()
)
def obtener_mensaje_error(self) -> str:
"""Devuelve el texto del mensaje de error si es visible."""
return self.esperar_visible(self._ERROR_MESSAGE).text
def ir_a_recuperar_password(self) -> RecuperarPasswordPage:
"""Hace clic en el enlace de recuperación de contraseña."""
from pages.recuperar_password_page import RecuperarPasswordPage
self.esperar_clickable(self._FORGOT_PASSWORD_LINK).click()
return RecuperarPasswordPage(self.driver)
# pages/dashboard_page.py
from __future__ import annotations
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class DashboardPage(BasePage):
"""Page Object para la página de dashboard."""
_BIENVENIDA_TITULO = (By.CSS_SELECTOR, "h1.dashboard-title")
_MENU_USUARIO = (By.CSS_SELECTOR, "[data-testid='user-menu']")
_CERRAR_SESION = (By.LINK_TEXT, "Cerrar sesión")
_CONTADOR_NOTIFICACIONES = (By.CLASS_NAME, "notification-count")
def esta_cargada(self) -> bool:
"""Verifica que el dashboard está completamente cargado."""
return (
"/dashboard" in self.driver.current_url
and self.driver.find_elements(*self._BIENVENIDA_TITULO) != []
)
def obtener_titulo_bienvenida(self) -> str:
"""Devuelve el título de bienvenida del dashboard."""
return self.esperar_visible(self._BIENVENIDA_TITULO).text
def obtener_notificaciones(self) -> int:
"""Devuelve el número de notificaciones no leídas."""
elementos = self.driver.find_elements(*self._CONTADOR_NOTIFICACIONES)
if not elementos:
return 0
return int(elementos[0].text)
def cerrar_sesion(self) -> LoginPage:
"""Cierra la sesión y devuelve la página de login."""
from pages.login_page import LoginPage
self.esperar_clickable(self._MENU_USUARIO).click()
self.esperar_clickable(self._CERRAR_SESION).click()
return LoginPage(self.driver)
Integración con pytest
# tests/conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage
@pytest.fixture(scope="function")
def driver():
options = Options()
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
d = webdriver.Chrome(options=options)
d.maximize_window()
yield d
d.quit()
@pytest.fixture
def login_page(driver):
"""Fixture que devuelve una instancia de LoginPage lista para usar."""
page = LoginPage(driver)
page.abrir()
return page
@pytest.fixture
def dashboard_page(driver):
"""Fixture que hace login y devuelve el DashboardPage."""
login = LoginPage(driver)
return login.abrir().login_como("admin@ejemplo.com", "admin123")
# tests/test_autenticacion.py
import pytest
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage
class TestLogin:
"""Suite de tests para el módulo de autenticación."""
def test_login_exitoso(self, login_page: LoginPage):
"""Un usuario válido puede iniciar sesión."""
dashboard = login_page.login_como("usuario@test.com", "contraseña123")
assert dashboard.esta_cargada()
assert "Bienvenido" in dashboard.obtener_titulo_bienvenida()
def test_login_con_email_incorrecto(self, login_page: LoginPage):
"""El sistema rechaza credenciales incorrectas."""
login_page.introducir_email("incorrecto@test.com")
login_page.introducir_password("contraseña123")
login_page.hacer_clic_login()
# No navegó al dashboard, hay mensaje de error
assert "/dashboard" not in login_page.obtener_url_actual()
assert login_page.obtener_mensaje_error() != ""
@pytest.mark.parametrize("email,password", [
("", "contraseña"),
("email@test.com", ""),
("no_es_email", "contraseña"),
])
def test_validaciones_formulario_login(
self, login_page: LoginPage, email: str, password: str
):
"""El formulario valida los campos antes de enviar."""
if email:
login_page.introducir_email(email)
if password:
login_page.introducir_password(password)
login_page.hacer_clic_login()
assert "/dashboard" not in login_page.obtener_url_actual()
def test_flujo_completo_login_logout(self, login_page: LoginPage):
"""Un usuario puede hacer login y luego logout correctamente."""
dashboard = login_page.login_como("usuario@test.com", "contraseña123")
assert dashboard.esta_cargada()
login_vacia = dashboard.cerrar_sesion()
assert login_vacia.esta_en_pagina("/login")
Estructura de proyecto recomendada
mi-suite-selenium/
├── pages/
│ ├── __init__.py
│ ├── base_page.py # Clase base con utilidades comunes
│ ├── login_page.py
│ ├── dashboard_page.py
│ ├── registro_page.py
│ └── catalogo_page.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures compartidas
│ ├── test_autenticacion.py
│ ├── test_catalogo.py
│ └── test_checkout.py
├── utils/
│ ├── __init__.py
│ └── datos_prueba.py # Datos de prueba y constantes
├── reportes/ # Generado por pytest-html
├── capturas/ # Capturas en fallos
├── requirements.txt
└── pytest.ini
Buenas prácticas del POM en Python
# BIEN: método expresivo que describe la acción del usuario
def completar_registro(self, nombre, email, password):
self.introducir_nombre(nombre)
self.introducir_email(email)
self.introducir_password(password)
return self.confirmar_registro()
# BIEN: sin aserciones en Page Objects
def obtener_mensaje_exito(self) -> str:
return self.esperar_visible(self._MENSAJE_EXITO).text
# MAL: aserción en Page Object (va en el test)
# def verificar_registro_exitoso(self):
# assert self.esperar_visible(self._MENSAJE_EXITO).text == "Registro completado"
# BIEN: type hints para mejor experiencia de desarrollo
def login_como(self, email: str, password: str) -> DashboardPage:
...
# BIEN: localizadores como constantes de clase
_BOTON_ENVIAR = (By.CSS_SELECTOR, "[data-testid='submit']")
Fuentes y referencias
Documentación oficial y recursos externos para profundizar en Selenium
Documentación oficial de Selenium
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Selenium es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Selenium
Explora más contenido relacionado con Selenium y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Crear clases Page Object en Python que encapsulen localizadores y acciones. Implementar el flujo de navegación entre páginas retornando nuevas instancias POM. Organizar un proyecto Selenium Python con POM, utils y tests bien estructurados. Combinar Page Object Model con fixtures de pytest para pruebas limpias. Aplicar buenas prácticas: sin aserciones en Page Objects, métodos expresivos y tipado. Implementar una BasePage con métodos comunes reutilizables en todos los Page Objects.