Cuando el botón "Submit" pasa a llamarse "Sign In", una suite sin Page Object Model requiere actualizar cada test que hace clic en él; con POM, cambias una sola línea en LoginPage.ts y el resto sigue. El patrón tiene un mal uso común: poner aserciones expect() dentro de los métodos del page object, lo que hace que los mensajes de fallo sean ambiguos y rompe la separación entre cómo interactuar con una página y qué verifica un test. Este artículo construye un LoginPage y un DashboardPage completos desde cero, y luego los conecta a fixtures de Playwright para que los tests reciban los page objects directamente en sus firmas en lugar de construirlos manualmente.

Qué resuelve realmente el Page Object Model

POM es un patrón de diseño, no una funcionalidad de Playwright. La idea: crear una clase que represente una página (o una sección de una página). La clase contiene los locators y los métodos que interactúan con esa página. Los tests usan la clase en lugar de comandos directos de Playwright.

Sin POM, los tests se ven así:

test('el usuario puede iniciar sesión', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

test('el login falla con contraseña incorrecta', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('contraseñaincorrecta');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

Con POM:

test('el usuario puede iniciar sesión', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

test('el login falla con contraseña incorrecta', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'contraseñaincorrecta');
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

Cuando el botón cambia de "Submit" a "Sign In", lo corriges en LoginPage.ts. Los dos tests siguen en verde sin tocarlos.

Construir tu primer Page Object

Crea una carpeta pages/ en la raíz del proyecto. Dentro, LoginPage.ts:

import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly loginButton: Locator;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
  }

  async login(username: string, password: string) {
    await this.loginButton.click();
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Dos cosas a notar. Primero, los locators se definen en el constructor como propiedades de clase. Este es el enfoque recomendado por Playwright. Los locators son lazy: no buscan en el DOM cuando se definen, solo cuando se usan. Segundo, los métodos representan acciones del usuario (goto, login), no clics individuales. Los tests llaman a acciones, no a detalles de implementación.

Agregar más páginas

Agrega un DashboardPage.ts para la página después del login:

import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly itemsTable: Locator;
  readonly addItemButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'My Travel Items' });
    this.itemsTable = page.getByRole('table');
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
  }

  async isLoaded() {
    await this.heading.waitFor({ state: 'visible' });
  }

  async getRowCount() {
    const rows = this.page.getByRole('row');
    return await rows.count() - 1; // restar la fila del header
  }

  async clickAddItem() {
    await this.addItemButton.click();
  }
}

Los tests ahora se leen como un flujo de usuario:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test('el dashboard muestra ítems después del login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await dashboardPage.isLoaded();

  const rowCount = await dashboardPage.getRowCount();
  expect(rowCount).toBeGreaterThan(0);
});

Usar una BasePage para comportamiento compartido

Si tienes 10 page objects y todos necesitan la misma lógica de navegación o un método waitForLoad común, ponlo en una clase base:

// pages/BasePage.ts
import { Page } from '@playwright/test';

export class BasePage {
  constructor(protected page: Page) {}

  async waitForNetworkIdle() {
    await this.page.waitForLoadState('networkidle');
  }

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

  async scrollToBottom() {
    await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  }
}

// pages/LoginPage.ts
import { Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  readonly loginButton: Locator;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: any) {
    super(page);
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
  }

  async login(username: string, password: string) {
    await this.loginButton.click();
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

No abuses de la herencia. Un nivel (BasePage a página específica) suele ser suficiente. Las cadenas de herencia profundas se vuelven difíciles de seguir.

Convertir page objects en fixtures

Crear new LoginPage(page) en cada test es repetitivo. Los fixtures de Playwright te permiten inyectar page objects automáticamente:

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect } from '@playwright/test';

Ahora importa test desde tu archivo de fixture en lugar de desde Playwright:

// tests/login.spec.ts
import { test, expect } from '../fixtures/pages.fixture';

test('el usuario puede iniciar sesión', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await expect(loginPage.page.getByText('My Travel Items')).toBeVisible();
});

test('el dashboard muestra ítems después del login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await dashboardPage.isLoaded();

  const rowCount = await dashboardPage.getRowCount();
  expect(rowCount).toBeGreaterThan(0);
});

Los page objects aparecen en la firma del test. Sin llamadas al constructor, sin imports de clases de página en los archivos de test, solo los objetos listos para usar.

Este patrón de fixture es el estándar profesional para proyectos de Playwright. Una vez que tenés más de 5 page objects, los fixtures hacen que los archivos de test sean notablemente más limpios que la construcción manual.

Qué va en un page object y qué no

Poner en los page objects

  • Locators para los elementos de la página
  • Métodos que representan acciones del usuario en la página
  • Condiciones de espera específicas de la página (isLoaded, waitForModal)
  • Getters de datos simples (getRowCount, getHeadingText)

Dejar en los archivos de test

  • Aserciones (expect(...))
  • Datos de prueba
  • Lógica del test (qué hacer, en qué orden)
  • Descripciones de lo que verifica el test

La regla: los page objects describen CÓMO interactuar con la página. Los tests describen QUÉ verificar. Si ponés aserciones dentro de los page objects, los tests se vuelven más difíciles de leer y los fallos más difíciles de diagnosticar.

// Mal: aserción dentro del page object
async login(username: string, password: string) {
  await this.loginButton.click();
  await this.usernameInput.fill(username);
  await this.passwordInput.fill(password);
  await this.submitButton.click();
  await expect(this.page.getByText('My Travel Items')).toBeVisible(); // no hacer esto
}

// Bien: el page object solo ejecuta la acción
async login(username: string, password: string) {
  await this.loginButton.click();
  await this.usernameInput.fill(username);
  await this.passwordInput.fill(password);
  await this.submitButton.click();
}
// El test hace la aserción
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();

Estructura de proyecto con POM

Una estructura de carpetas limpia para un proyecto basado en POM:

proyecto/
  pages/
    BasePage.ts
    LoginPage.ts
    DashboardPage.ts
    ItemsPage.ts
  fixtures/
    pages.fixture.ts
    auth.fixture.ts
  tests/
    auth/
      login.spec.ts
      logout.spec.ts
    items/
      items-list.spec.ts
      items-crud.spec.ts
    api/
      items-api.spec.ts
  playwright.config.ts
  package.json

Crea un page object solo cuando tienes dos o más tests que interactúan con la misma página. Un solo test que usa una página directamente está bien. El overhead de una clase no vale la pena para un único test.

FAQ

¿Debería cada página de la app tener un page object?

Solo las páginas que tienen tests automatizados. Si tienes una página de configuración sin tests, no crees un SettingsPage.ts solo por tenerlo.

Mi page object se está haciendo enorme, 500 líneas. ¿Qué hago?

Divídelo por sección. Una página con una tabla, un modal y una barra lateral puede convertirse en ItemsTablePage, AddItemModal y un ItemsPage padre que los compone. O extraé el modal a su propia clase e importalo en la página.

¿Puedo usar page objects para tests de API?

El patrón a veces se llama "API Objects": una clase que envuelve las llamadas API relacionadas de la misma manera que un page object envuelve las interacciones de UI. Es útil cuando muchos tests llegan a los mismos endpoints. No es obligatorio, pero aplican los mismos beneficios.

¿Cuándo debería cambiar de no-POM a POM?

Cuando te encuentras copiando y pegando las mismas 4 o 5 líneas por tercera vez. Esa es la señal. No construyas page objects de forma especulativa antes de saber que los necesitas.

→ See also: Fixtures Personalizados en Playwright: El Patrón que Hace los Tests Legibles | Patrones Avanzados de Page Object en Playwright | Interfaces y Tipos de TypeScript para Page Object Model | Clases JavaScript para Ingenieros QA: Construyendo Page Objects