Page Object Model строится на JavaScript-классах: класс LoginPage хранит локаторы в конструкторе, методы оборачивают взаимодействия, а через extends наследует общую навигацию от BasePage. Самая частая ошибка при изучении паттерна: забыть super() первой строкой конструктора дочернего класса, из-за чего локаторы родителя не настраиваются. В этом гайде: конструкторы, this, extends, super и статические методы, всё строится к полному примеру page object.

Что такое класс

Класс служит шаблоном для создания объектов. Каждый LoginPage созданный из класса получает одинаковые методы, но работает независимо.

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();
  }
}

Использование:

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

Конструктор

Конструктор запускается при создании нового экземпляра через new. Здесь инициализируются свойства.

class ProductPage {
  constructor(page) {
    // 'this' указывает на создаваемый экземпляр
    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';
  }
}

// При вызове new ProductPage(page):
// - конструктор запускается сразу
// - 'this.page' присваивается переданный аргумент page
// - локаторы сохраняются в экземпляре
const productPage = new ProductPage(page);

this в классах

this указывает на текущий экземпляр. Через него методы обращаются к данным экземпляра.

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' - локатор сохранённый в конструкторе
    return await this.userName.textContent();
  }

  async logout() {
    // 'this.logoutButton' - локатор кнопки
    await this.logoutButton.click();
  }

  async verifyUser(expectedName) {
    // Методы могут вызывать другие методы через 'this'
    const name = await this.getUserName();
    if (name !== expectedName) {
      throw new Error(`Expected ${expectedName}, got ${name}`);
    }
  }
}

Методы

Функции определённые на классе называются методами. Могут быть синхронными или асинхронными.

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');
  }

  // Простой асинхронный метод
  async search(query) {
    await this.searchInput.fill(query);
    await this.searchButton.click();
  }

  // Метод возвращающий значение
  async getResultCount() {
    return await this.results.count();
  }

  // Метод из нескольких шагов
  async searchAndVerify(query, expectedCount) {
    await this.search(query);
    await this.page.waitForLoadState('networkidle');
    const count = await this.getResultCount();
    return count === expectedCount;
  }
}

Наследование через extends

Наследование позволяет одному классу строиться поверх другого. В POM обычно есть BasePage от которого наследуются все страницы.

// Базовый класс с общим поведением
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(name) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

// Дочерний класс наследует всё от BasePage
class LoginPage extends BasePage {
  constructor(page) {
    super(page);  // Обязательно первой строкой: запускает конструктор 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');  // Унаследован от BasePage
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

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

Теперь LoginPage имеет и свои методы, и всё от BasePage:

const loginPage = new LoginPage(page);
await loginPage.login('user@test.com', 'pass');     // метод LoginPage
await loginPage.takeScreenshot('after-login');       // метод BasePage
const title = await loginPage.getTitle();            // метод BasePage

super: вызов родителя

super() вызывает конструктор родительского класса. Обязателен первой строкой в конструкторе дочернего класса.

class AdminPage extends BasePage {
  constructor(page) {
    super(page);  // Запускает конструктор BasePage: устанавливает this.page, this.header, this.footer
    // Теперь добавляем специфику AdminPage
    this.userTable = page.getByTestId('users-table');
    this.addUserButton = page.getByTestId('add-user');
  }
}

Родительские методы вызываются через super.methodName():

class CheckoutPage extends BasePage {
  async navigate() {
    // Вызываем родительский navigate, потом добавляем своё
    await super.navigate('/checkout');
    await this.page.waitForSelector('[data-testid="checkout-form"]');
  }
}

Статические методы

Статические методы принадлежат самому классу, а не экземплярам. Полезны для утилитарных функций.

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

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

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

// Используешь без 'new': вызываешь прямо на классе
const email = TestData.generateEmail();
const user = TestData.generateUser();
const product = TestData.generateProduct({ price: 49.99 });

Геттеры

Геттеры выглядят как свойства, но запускают функцию при обращении:

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

  get url() {
    return '/cart';  // Вычисляемое свойство
  }

  async navigate() {
    await super.navigate(this.url);  // Использует геттер
  }

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

const cartPage = new CartPage(page);
console.log(cartPage.url);  // '/cart' (скобки не нужны)

Полный пример page object

Всё вместе: реалистичный page object.

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('/products');
    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();
  }
}

// В тестах
test('фильтрация товаров по категории', async ({ page }) => {
  const products = new ProductListPage(page);
  await products.navigate();
  await products.filterByCategory('electronics');
  
  const count = await products.getProductCount();
  expect(count).toBeGreaterThan(0);
});

Класс vs объектный литерал

Для одноразового использования подойдёт объектный литерал. Для того что переиспользуется во многих тестах уместен класс.

// Объектный литерал: нормально для констант
const TEST_USERS = {
  admin: { email: 'admin@test.com', password: 'AdminPass1' },
  member: { email: 'member@test.com', password: 'MemberPass1' },
};

// Класс: хорошо для page objects используемых во многих тестах
class LoginPage extends BasePage {
  // ...
}

Краткое резюме

  • constructor(page): запускается при new, сохраняй локаторы здесь
  • this: указывает на экземпляр; через него методы обращаются к данным объекта
  • extends: наследование от другого класса (например BasePage)
  • super(): вызов конструктора родителя; обязательная первая строка в дочернем конструкторе
  • static: методы уровня класса, экземпляр не нужен
  • Методы могут быть асинхронными: возвращают промисы, внутри используется await
→ See also: Page Object Model в Playwright: от хаоса к поддерживаемым тестам | TypeScript интерфейсы и типы для Page Object Model | JavaScript для QA-инженеров: необходимый минимум для автоматизации