El Page Object Model es clases de JavaScript aplicadas a la automatización de navegadores: una clase LoginPage tiene un constructor que almacena locators, métodos que encapsulan interacciones, y hereda la navegación común de una BasePage mediante extends. El error más común cuando se aprende este patrón es olvidar super() como primera línea del constructor de la clase hija, lo que impide que se configuren los locators del padre. Esta guía cubre constructores, this, extends, super y métodos estáticos de utilidad, construyendo hasta un ejemplo completo de page object.

Qué es una clase

Una clase es un plano para crear objetos. Cada LoginPage que creas a partir de la clase obtiene los mismos métodos pero opera de forma independiente.

class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton = page.getByTestId('submit-btn');
  }

  async navigate() {
    await this.page.goto('/login');
  }

  async login(email, password) {
    await this.navigate();
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Luego la usas así:

const loginPage = new LoginPage(page);
await loginPage.login('usuario@test.com', 'PassValida1');

El constructor

El constructor se ejecuta cuando creas una nueva instancia con new. Es donde inicializas las propiedades.

class ProductPage {
  constructor(page) {
    // 'this' hace referencia a la instancia que se está creando
    this.page = page;
    this.productTitle = page.getByTestId('product-title');
    this.addToCartButton = page.getByTestId('add-to-cart');
    this.price = page.getByTestId('product-price');
    this.baseURL = '/products';
  }
}

// Cuando llamás new ProductPage(page):
// - el constructor corre inmediatamente
// - 'this.page' se asigna al argumento page
// - los locators se almacenan en la instancia
const productPage = new ProductPage(page);

this en las clases

this hace referencia a la instancia actual. Es cómo los métodos acceden a los datos de la instancia.

class DashboardPage {
  constructor(page) {
    this.page = page;
    this.welcomeMessage = page.getByTestId('welcome');
    this.userName = page.getByTestId('user-name');
    this.logoutButton = page.getByTestId('logout');
  }

  async getUserName() {
    // 'this.userName' es el locator almacenado en el constructor
    return await this.userName.textContent();
  }

  async logout() {
    // 'this.logoutButton' es el locator del botón
    await this.logoutButton.click();
  }

  async verifyUser(nombreEsperado) {
    // Los métodos pueden llamar a otros métodos en 'this'
    const nombre = await this.getUserName();
    if (nombre !== nombreEsperado) {
      throw new Error(`Se esperaba ${nombreEsperado}, se obtuvo ${nombre}`);
    }
  }
}

Métodos

Los métodos son funciones definidas en una clase. Pueden ser síncronos o async.

class SearchPage {
  constructor(page) {
    this.page = page;
    this.searchInput = page.getByTestId('search-input');
    this.searchButton = page.getByTestId('search-button');
    this.results = page.getByTestId('search-result');
    this.noResultsMessage = page.getByTestId('no-results');
  }

  // Método async simple
  async search(query) {
    await this.searchInput.fill(query);
    await this.searchButton.click();
  }

  // Método que devuelve un valor
  async getResultCount() {
    return await this.results.count();
  }

  // Método con múltiples pasos
  async searchAndVerify(query, countEsperado) {
    await this.search(query);
    await this.page.waitForLoadState('networkidle');
    const count = await this.getResultCount();
    return count === countEsperado;
  }
}

Herencia con extends

La herencia permite que una clase se construya sobre otra. En POM, generalmente tienes una BasePage que todas las páginas extienden.

// Clase base con comportamiento común
class BasePage {
  constructor(page) {
    this.page = page;
    this.header = page.getByTestId('header');
    this.footer = page.getByTestId('footer');
  }

  async navigate(path) {
    await this.page.goto(path);
  }

  async getTitle() {
    return await this.page.title();
  }

  async takeScreenshot(nombre) {
    await this.page.screenshot({ path: `screenshots/${nombre}.png` });
  }
}

// La clase hija hereda todo de BasePage
class LoginPage extends BasePage {
  constructor(page) {
    super(page);  // Debe llamarse primero — ejecuta el constructor de BasePage
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton = page.getByTestId('submit-btn');
    this.errorMessage = page.getByTestId('error-message');
  }

  async login(email, password) {
    await this.navigate('/login');  // Heredado de BasePage
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorText() {
    return await this.errorMessage.textContent();
  }
}

Ahora LoginPage tiene sus propios métodos Y todo lo de BasePage:

const loginPage = new LoginPage(page);
await loginPage.login('usuario@test.com', 'pass');    // método de LoginPage
await loginPage.takeScreenshot('despues-login');       // método de BasePage
const titulo = await loginPage.getTitle();             // método de BasePage

super: llamar al padre

super() llama al constructor de la clase padre. Es obligatorio como primera línea en el constructor de una clase hija.

class AdminPage extends BasePage {
  constructor(page) {
    super(page);  // Ejecuta el constructor de BasePage: asigna this.page, this.header, this.footer
    // Ahora agrega las cosas específicas de AdminPage
    this.userTable = page.getByTestId('users-table');
    this.addUserButton = page.getByTestId('add-user');
  }
}

También puedes llamar a métodos del padre con super.nombreMetodo():

class CheckoutPage extends BasePage {
  async navigate() {
    // Llamar al navigate del padre, luego hacer cosas extra
    await super.navigate('/checkout');
    await this.page.waitForSelector('[data-testid="checkout-form"]');
  }
}

Métodos estáticos

Los métodos estáticos pertenecen a la clase en sí, no a las instancias. Son útiles para funciones de utilidad.

class TestData {
  static generateEmail() {
    return `user_${Date.now()}@test.com`;
  }

  static generateUser() {
    return {
      email: TestData.generateEmail(),
      password: 'PassValida1',
      name: 'Usuario Test',
    };
  }

  static generateProduct(overrides = {}) {
    return {
      name: 'Producto Test',
      price: 99.99,
      category: 'electronica',
      ...overrides,
    };
  }
}

// Usar sin 'new': llamar directamente en la clase
const email = TestData.generateEmail();
const usuario = TestData.generateUser();
const producto = TestData.generateProduct({ price: 49.99 });

Getters

Los getters parecen propiedades pero ejecutan una función cuando se accede a ellos:

class CartPage extends BasePage {
  constructor(page) {
    super(page);
    this.items = page.getByTestId('cart-item');
    this.totalElement = page.getByTestId('cart-total');
  }

  get url() {
    return '/carrito';  // Propiedad calculada
  }

  async navigate() {
    await super.navigate(this.url);  // Usa el getter
  }

  async getItemCount() {
    return await this.items.count();
  }
}

const cartPage = new CartPage(page);
console.log(cartPage.url);  // '/carrito' — sin () necesarios

Un ejemplo completo de page object

Todo junto, un page object realista:

class ProductListPage extends BasePage {
  constructor(page) {
    super(page);
    this.productCards = page.getByTestId('product-card');
    this.filterDropdown = page.getByTestId('category-filter');
    this.sortDropdown = page.getByTestId('sort-select');
    this.searchInput = page.getByTestId('product-search');
    this.loadingSpinner = page.getByTestId('loading');
  }

  async navigate() {
    await super.navigate('/productos');
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

  async filterByCategory(category) {
    await this.filterDropdown.selectOption(category);
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

  async sortBy(sortOption) {
    await this.sortDropdown.selectOption(sortOption);
  }

  async search(query) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

  async getProductCount() {
    return await this.productCards.count();
  }

  async getProductNames() {
    return await this.productCards.getByTestId('product-name').allTextContents();
  }

  async clickProduct(index) {
    await this.productCards.nth(index).click();
  }
}

// En los tests
test('filtrar productos por categoría', async ({ page }) => {
  const productos = new ProductListPage(page);
  await productos.navigate();
  await productos.filterByCategory('electronica');

  const count = await productos.getProductCount();
  expect(count).toBeGreaterThan(0);
});

Clase vs. objeto literal

Para algo de uso único, un objeto literal es más simple. Para algo que reutilizas en varios tests, una clase tiene sentido.

// Objeto literal: bien para constantes
const TEST_USERS = {
  admin: { email: 'admin@test.com', password: 'AdminPass1' },
  member: { email: 'member@test.com', password: 'MemberPass1' },
};

// Clase: buena para page objects usados en muchos tests
class LoginPage extends BasePage {
  // ...
}

Resumen

  • constructor(page): corre con new, almacena los locators acá
  • this: hace referencia a la instancia; cómo los métodos acceden a los datos del objeto
  • extends: heredar de otra clase (por ejemplo, BasePage)
  • super(): llama al constructor del padre; es la primera línea obligatoria en el constructor de la clase hija
  • static: métodos a nivel de clase, sin necesidad de instancia
  • Los métodos pueden ser async: devuelven promesas, usa await dentro
→ See also: Page Object Model en Playwright: De Caótico a Mantenible | Interfaces y Tipos de TypeScript para Page Object Model | JavaScript para QA Engineers: El Mínimo que Necesitas para Empezar a Automatizar