Что такое Page Object Model?

Page Object Model (POM) — наиболее распространённый паттерн проектирования в UI-автоматизации. Он создаёт класс для каждой страницы или компонента приложения, инкапсулируя все взаимодействия с этой страницей.

Без POM — селекторы и действия разбросаны по тестам:

test('пользователь может войти', async ({ page }) => {
  await page.fill('#email', 'admin@test.com');
  await page.fill('#password', 'secret');
  await page.click('button.login-btn');
  await expect(page.locator('.welcome-msg')).toHaveText('Добро пожаловать, Admin');
});

С POM — детали страницы инкапсулированы:

test('пользователь может войти', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('admin@test.com', 'secret');
  const dashboard = new DashboardPage(page);
  await expect(dashboard.welcomeMessage).toHaveText('Добро пожаловать, Admin');
});

Основные принципы

1. Один класс на страницу (или компонент)

Каждая страница приложения получает свой класс:

pages/
  LoginPage.ts
  DashboardPage.ts
  ProductListPage.ts
  ProductDetailPage.ts
  CheckoutPage.ts
  CartPage.ts

2. Селекторы — приватные

Селекторы элементов — детали реализации, которые не должны просачиваться в тесты:

class LoginPage {
  #emailInput = '[data-testid="email"]';
  #passwordInput = '[data-testid="password"]';
  #submitBtn = '[data-testid="login-submit"]';
  #errorMessage = '.login-error';

  constructor(page) {
    this.page = page;
  }
}

3. Публичные методы — действия пользователя

Методы именуются по тому, что делает пользователь, а не код:

class LoginPage {
  // Хорошо — пользовательские имена методов
  async login(email, password) { /* ... */ }
  async forgotPassword(email) { /* ... */ }
  async loginWithGoogle() { /* ... */ }

  // Плохо — имена, ориентированные на реализацию
  async fillEmailAndClickSubmit() { /* ... */ }
  async clickGoogleButton() { /* ... */ }
}

4. Никаких ассертов в page objects

Page objects выполняют действия и возвращают данные. Тесты делают проверки:

// Page object — возвращает данные, без ассертов
class DashboardPage {
  get welcomeMessage() {
    return this.page.locator('.welcome-msg');
  }

  async getUserName() {
    return await this.page.textContent('.user-name');
  }

  async getNotificationCount() {
    const text = await this.page.textContent('.notification-badge');
    return parseInt(text);
  }
}

// Тест — делает проверки
test('дашборд показывает имя пользователя', async ({ page }) => {
  const dashboard = new DashboardPage(page);
  await expect(dashboard.welcomeMessage).toContainText('Admin');
  const count = await dashboard.getNotificationCount();
  expect(count).toBeGreaterThan(0);
});

Полный пример Page Object

export class LoginPage {
  #emailInput = '[data-testid="email"]';
  #passwordInput = '[data-testid="password"]';
  #submitBtn = '[data-testid="login-submit"]';
  #errorMessage = '[data-testid="login-error"]';
  #googleOAuth = '[data-testid="google-login"]';

  constructor(page) {
    this.page = page;
  }

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

  async login(email, password) {
    await this.page.fill(this.#emailInput, email);
    await this.page.fill(this.#passwordInput, password);
    await this.page.click(this.#submitBtn);
  }

  async loginWithGoogle() {
    await this.page.click(this.#googleOAuth);
  }

  get errorMessage() {
    return this.page.locator(this.#errorMessage);
  }

  async isLoaded() {
    await this.page.waitForSelector(this.#emailInput);
  }
}

Цепочки методов (Method Chaining)

Цепочки методов создают плавный синтаксис тестов, возвращая page objects из методов навигации:

class LoginPage {
  async login(email, password) {
    await this.page.fill(this.#emailInput, email);
    await this.page.fill(this.#passwordInput, password);
    await this.page.click(this.#submitBtn);
    return new DashboardPage(this.page);
  }
}

class DashboardPage {
  async navigateToProfile() {
    await this.page.click('#profile-link');
    return new ProfilePage(this.page);
  }
}

// Плавный тест
test('пользователь переходит в профиль', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  const dashboard = await loginPage.login('admin@test.com', 'secret');
  const profile = await dashboard.navigateToProfile();
  await expect(profile.nameField).toHaveValue('Admin User');
});

Component Objects

Для UI-элементов, присутствующих на нескольких страницах (навигация, футер, модалы), создавайте component objects:

class NavBar {
  constructor(page) {
    this.page = page;
  }

  async searchFor(query) {
    await this.page.fill('#search', query);
    await this.page.press('#search', 'Enter');
  }

  async navigateTo(section) {
    await this.page.click(`nav a[href="/${section}"]`);
  }

  async logout() {
    await this.page.click('#user-menu');
    await this.page.click('#logout');
  }
}

// Композиция в page objects
class DashboardPage {
  constructor(page) {
    this.page = page;
    this.navBar = new NavBar(page);
  }
}

Продвинутые паттерны POM

Базовый класс страницы

class BasePage {
  constructor(page) {
    this.page = page;
    this.navBar = new NavBar(page);
    this.footer = new Footer(page);
  }

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

  async waitForPageLoad() {
    await this.page.waitForLoadState('domcontentloaded');
  }

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

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

Работа с динамическими элементами

class ProductListPage extends BasePage {
  productCard(name) {
    return this.page.locator(`.product-card:has-text("${name}")`);
  }

  async addToCart(productName) {
    const card = this.productCard(productName);
    await card.locator('.add-to-cart-btn').click();
  }

  async getPrice(productName) {
    const card = this.productCard(productName);
    const priceText = await card.locator('.price').textContent();
    return parseFloat(priceText.replace('$', ''));
  }

  async getProductCount() {
    return await this.page.locator('.product-card').count();
  }
}

Обработка ожиданий и состояний загрузки

class CheckoutPage extends BasePage {
  async submitPayment(cardNumber) {
    await this.page.fill('#card-number', cardNumber);
    await this.page.fill('#expiry', '12/28');
    await this.page.fill('#cvv', '123');

    const [response] = await Promise.all([
      this.page.waitForResponse(resp =>
        resp.url().includes('/api/payment') && resp.status() === 200
      ),
      this.page.click('#pay-button')
    ]);

    return new OrderConfirmationPage(this.page);
  }
}

POM с Playwright Fixtures

Playwright нативно поддерживает POM через фикстуры:

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

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

test('пользователь видит дашборд', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@test.com', 'secret');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

Анти-паттерны POM

1. God Page Object

Page object с 50+ методами — слишком большой. Разделите на меньшие, сфокусированные объекты.

2. Ассерты в Page Objects

Ассерты — в тестах. Page objects возвращают данные; тесты проверяют.

3. Открытые селекторы

Тесты не должны напрямую обращаться к селекторам.

4. Общее изменяемое состояние

Page objects не должны хранить состояние, специфичное для теста.

Упражнение: Постройте полный POM

Создайте page objects для процесса оформления заказа в e-commerce:

  1. LoginPage — goto(), login(), loginWithGoogle(), errorMessage
  2. ProductListPage — searchProduct(), filterByCategory(), addToCart(), getProductCount()
  3. CartPage — getItems(), updateQuantity(), removeItem(), getTotal(), proceedToCheckout()
  4. CheckoutPage — enterShipping(), enterPayment(), placeOrder()
  5. OrderConfirmationPage — getOrderNumber(), getTotal(), getEstimatedDelivery()
  6. NavBar (компонент) — search(), navigateTo(), cartCount(), logout()

Напишите 3 тест-кейса, использующих эти page objects.

Ключевые выводы

  • POM отделяет детали страницы от логики тестов — одна точка обновления на изменение UI
  • Селекторы приватные, публичны только методы пользовательских действий
  • Никаких ассертов в page objects — возвращайте данные, проверяйте в тестах
  • Component objects для общих UI-элементов (навигация, футер, модалы)
  • Цепочки методов делают тесты плавными и читаемыми
  • Playwright fixtures естественно интегрируются с POM