Когда кнопка «Submit» переименовывается в «Sign In», набор тестов без Page Object Model требует обновления каждого теста который её нажимает. С POM меняешь одну строку в LoginPage.ts и всё остальное подтягивается. Главная ошибка при использовании паттерна: помещать expect() внутрь методов page object, из-за чего сообщения об ошибках становятся неоднозначными и нарушается разделение между тем как взаимодействовать со страницей и что проверяет тест. Статья строит полноценные LoginPage и DashboardPage с нуля, а затем подключает их к фикстурам Playwright так чтобы тесты получали page objects прямо в сигнатуре вместо ручного создания.

Что на самом деле решает Page Object Model

POM: паттерн проектирования, не фича Playwright. Идея: создаёшь класс который представляет страницу (или её секцию). Класс содержит локаторы и методы для взаимодействия с этой страницей. Тесты используют класс вместо прямых команд Playwright.

Без POM тесты выглядят так:

test('user can log in', 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('login fails with wrong password', 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('wrongpassword');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

С POM:

test('user can log in', 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('login fails with wrong password', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'wrongpassword');
  await expect(page.getByText('Invalid credentials')).toBeVisible();
});

Когда кнопка меняется с «Submit» на «Sign In», правишь в LoginPage.ts. Оба теста остаются зелёными без изменений.

Первый page object

Создай папку pages/ в корне проекта. Внутри: 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();
  }
}

Два момента. Первый: локаторы объявляются в конструкторе как свойства класса. Это рекомендованный Playwright подход. Локаторы ленивые: они не ищут DOM при объявлении, только при использовании. Второй: методы представляют действия пользователя (goto, login), а не отдельные клики. Тесты вызывают действия, не детали реализации.

Добавляем больше страниц

DashboardPage.ts для страницы после логина:

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; // вычитаем строку заголовка
  }

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

Тесты теперь читаются как пользовательский сценарий:

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

test('dashboard shows items after 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);
});

BasePage для общей логики

Если page objects 10 штук и каждому нужна одна и та же навигационная логика или общий метод waitForLoad, выноси это в базовый класс:

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

Не злоупотребляй наследованием. Один уровень (BasePage и конкретная страница) обычно достаточно. Глубокие цепочки наследования сложно читать.

Page objects как фикстуры

Создавать new LoginPage(page) в каждом тесте утомительно. Фикстуры Playwright позволяют внедрять page objects автоматически:

// 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';

Теперь импортируешь test из файла фикстуры вместо Playwright:

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

test('user can log in', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');
  await expect(loginPage.page.getByText('My Travel Items')).toBeVisible();
});

test('dashboard shows items after 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);
});

Page objects приходят прямо в сигнатуру теста. Никаких вызовов конструктора, никаких импортов классов в тестовых файлах.

Паттерн с фикстурами: профессиональный стандарт для Playwright-проектов. Когда page objects больше пяти, фикстуры делают тестовые файлы заметно чище чем ручное создание объектов.

Что идёт в page object, а что нет

В page object

  • Локаторы элементов страницы
  • Методы представляющие действия пользователя на странице
  • Условия ожидания специфичные для страницы (isLoaded, waitForModal)
  • Простые геттеры данных (getRowCount, getHeadingText)

В тестовых файлах

  • Ассёрты (expect(...))
  • Тестовые данные
  • Тестовая логика (что делать и в каком порядке)
  • Описание что именно проверяет тест

Правило: page objects описывают КАК взаимодействовать со страницей. Тесты описывают ЧТО проверять. Ассёрты внутри page objects усложняют чтение тестов и затрудняют диагностику падений.

// Неправильно — ассёрт внутри 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(); // не делай так
}

// Правильно — 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 loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();

Структура проекта с POM

Чистая структура папок для проекта на основе POM:

project/
  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

Создавай page object только когда два и более теста взаимодействуют с одной страницей. Один тест который работает со страницей напрямую: это нормально. Накладные расходы класса не оправданы для единственного теста.

FAQ

Нужен ли page object для каждой страницы приложения?

Только для страниц с автоматизированными тестами. Если есть страница настроек без тестов, не создавай SettingsPage.ts просто ради порядка.

Page object раздулся до 500 строк. Что делать?

Разбивай по секциям. Страница с таблицей, модалкой и сайдбаром может стать ItemsTablePage, AddItemModal и родительским ItemsPage который их объединяет. Или выноси модалку в отдельный класс и импортируй в страницу.

Можно использовать page objects для API-тестов?

Паттерн иногда называют «API Objects»: класс оборачивает связанные API-вызовы так же как page object оборачивает UI-взаимодействия. Полезно когда много тестов обращаются к одним и тем же эндпоинтам. Не обязательно, но преимущества те же.

Когда переходить от тестов без POM к POM?

Когда ловишь себя на копировании одних и тех же 4–5 строк в третий раз. Это сигнал. Не строй page objects заранее. Только когда точно знаешь что они нужны.

→ See also: Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Продвинутые паттерны Page Object в Playwright | TypeScript интерфейсы и типы для Page Object Model | Классы JavaScript для QA инженеров: построение Page Objects