Когда кнопка «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 приходят прямо в сигнатуру теста. Никаких вызовов конструктора, никаких импортов классов в тестовых файлах.
Что идёт в 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.jsonFAQ
Нужен ли 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