Page Object: від класики до компонентів
Зміст
Уяви сюїт із двохсот UI-тестів, у кожному з яких десь усередині написано page.locator('#login-btn').click(). Розробники перейменували кнопку на #signin-btn — і ти правиш той самий рядок у двохстах місцях, ризикуючи пропустити десяток. Це не гіпотетичний жах: саме так виглядає автоматизація без жодного шару абстракції над сторінкою. Локатори (locator) розповзаються по тестах, кроки дублюються, а будь-яка косметична зміна фронтенду перетворює зелений сюїт на червоний не через баг, а через крихкість самих тестів.
Page Object Model (POM) — це найстаріша і найважливіша відповідь індустрії на цю проблему, і тому на співбесіді middle-рівня питання «розкажи про Page Object» майже неминуче. Але справжня перевірка не в тому, чи знаєш ти означення, а чи розумієш межі патерну: де він рятує, де перетворюється на god object, і чому та сама ідея працює далеко за межами браузера. Ця глава — про механізм, а не про заучену формулу.
Проблема, яку розв'язує POM
Тест має відповідати на питання «що перевіряємо», а не «як саме дістатися до елемента». Коли ці два рівні змішані в одному файлі, ти отримуєш дві біди одразу: дублювання і крихкість.
Дублювання. Логін повторюється на початку десятків тестів. Якщо сценарій входу описаний прямо в тесті — заповнити поле email, заповнити поле пароля, натиснути кнопку — то він скопійований усюди, де потрібен залогінений користувач.
Крихкість. Локатор #login-btn — це деталь реалізації UI. Коли верстка змінюється, деталь протікає в кожен тест, який її згадує. Тест ламається не тому, що продукт зламався, а тому, що ми прив'язали перевірку бізнес-логіки до випадкового селектора.
POM розводить ці рівні. Об'єкт сторінки (page object) — це клас, який інкапсулює все знання про конкретний екран: як знайти на ньому елементи і які дії над ним доступні. Тест працює з методами цього класу мовою домену (loginPage.login(user)), а не мовою DOM. Локатор кнопки живе рівно в одному місці. Перейменували кнопку — правиш один рядок в одному класі, і всі двісті тестів лагодяться самі.
Ключове слово тут — єдине джерело правди (single source of truth). Це прямий наслідок принципу DRY (детальніше — у главі «Принципи дизайну тестового коду») і того самого ООП-мислення, з якого ми почали розділ (див. «ООП на тестовому коді»): клас ховає складність за зрозумілим інтерфейсом.
Сторінка = локатори + дії
Класичний page object складається рівно з двох частин: локатори (де що лежить) і дії (що з цим можна зробити). Нічого більше в мінімальній формі не потрібно.
import { Page, Locator } from '@playwright/test';
export class LoginPage {
private readonly email: Locator;
private readonly password: Locator;
private readonly submit: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Пароль');
this.submit = page.getByRole('button', { name: 'Увійти' });
}
async open(): Promise<void> {
await this.page.goto('/login');
}
async login(user: { email: string; password: string }): Promise<void> {
await this.email.fill(user.email);
await this.password.fill(user.password);
await this.submit.click();
}
}
Зверни увагу на три речі. По-перше, локатори — private: тест не має до них лізти напряму, інакше деталь реалізації знову протече назовні. По-друге, дії названі мовою користувача (login, open), а не мовою механіки (clickSubmitButton) — метод описує намір, а не послідовність кліків. По-третє, який саме селектор ми вибрали (getByRole, getByLabel, data-testid) — окреме питання зі своєю главою «Локатори: стратегія стабільних селекторів»; POM лише каже, що це рішення має бути прийняте в одному місці.
У Playwright є важливий нюанс, який спрощує життя: Locator — «лінивий». Він не шукає елемент у момент створення в конструкторі, а лише зберігає рецепт пошуку і виконує його щоразу під час дії. Тому оголошувати локатори в конструкторі безпечно навіть до того, як сторінка завантажилась. У класичному Selenium інакше: @FindBy/PageFactory дають WebElement — посилання на конкретний знайдений вузол DOM, і коли сторінка перемальовується, воно «протухає». Звідси класична пастка StaleElementReferenceException, якої лінивий локатор Playwright не має, бо перешукує елемент під кожну дію.
Важлива дисципліна: page object не робить expect, але і не вертає сирі елементи. Він повертає дані або стан, а не Locator. Метод getErrorText(): Promise<string> — добре; метод, що віддає назовні Locator поля, — погано, бо це та сама протікаюча абстракція.
Перевірки в page object: холівар
Ось найгарячіша суперечка навколо POM, і на співбесіді відповідь «залежить» тут не відмазка, а правильна позиція — якщо ти можеш пояснити, від чого саме залежить.
Табір «page object без асертів». Мартін Фаулер у канонічній статті радить: page object не повинен містити перевірок. Його робота — надати доступ до стану сторінки; вирішувати, що з цього стану правильне, — робота тесту. Аргументи: об'єкт сторінки стає перевикористовним у будь-якому сценарії (і в позитивному, і в негативному), а expect живе там, де йому місце — у тесті, поряд з описом очікуваного результату. Сам Фаулер робить один виняток: перевірка інваріантів (чи це взагалі та сторінка і чи вона коректно завантажилась) у page object доречна — на відміну від перевірок конкретного сценарію.
// PO лише віддає стан
async getErrorMessage(): Promise<string> {
return (await this.errorBanner.textContent()) ?? '';
}
// А перевірка — у тесті
test('вхід з чужим паролем показує помилку', async ({ page }) => {
const login = new LoginPage(page);
await login.open();
await login.login({ email: 'a@b.co', password: 'wrong' });
expect(await login.getErrorMessage()).toContain('Невірні дані');
});
Табір «перевірки-методи всередині». Практики заперечують: винести назовні геть усі перевірки означає, що прості асерти дублюються в багатьох тестах. Компроміс — виносити в page object дрібні assert-методи, які інкапсулюють складне очікування (наприклад, «дочекатися, поки таблиця перемалюється, і переконатися, що рядок з'явився»), але тримати бізнес-перевірки в тестах.
Практичний орієнтир, який рятує в обох світах: web-first assertions Playwright роблять цей холівар менш болючим. Оскільки expect(locator).toHaveText(...) сам чекає й ретраїть, спокуса ховати ручні очікування всередині page object слабшає — можна віддати Locator через тонкий геттер лише для асерту й перевіряти в тесті без сирого waitFor. Головне правило залишається: не змішуй у методі і дію, і перевірку її результату — інакше метод не перевикористаєш у сценарії, де очікується протилежне. Це прямо перегукується з принципом атомарності з глави «Анатомія автотесту».
Fluent-інтерфейс
Fluent-інтерфейс (fluent interface) — стиль, коли методи повертають об'єкт, щоб виклики зчіплювались у ланцюжок, який читається майже як речення. У Selenium-світі (особливо на Java) це популярний прийом:
loginPage
.typeEmail('a@b.co')
.typePassword('secret')
.submit();
Щоб так працювало, кожен метод повертає this. Виглядає гарно, але в асинхронному JS/TS має підводний камінь: методи Playwright — async, тож повертають Promise, і чистого синхронного зчеплення не вийде — доведеться повертати Promise<this> й городити await (await po.typeEmail()).typePassword(), що читається гірше, ніж звичайні окремі await. Тому в Playwright «класичний» fluent через this майже не використовують.
Натомість є набагато корисніша форма fluent-ідеї — повертати наступний page object з методу навігації. Метод, який виконує перехід між екранами, віддає об'єкт того екрана, куди ти потрапив:
async login(user: User): Promise<DashboardPage> {
await this.email.fill(user.email);
await this.password.fill(user.password);
await this.submit.click();
return new DashboardPage(this.page);
}
// У тесті потік читається як маршрут користувача:
const dashboard = await new LoginPage(page).login(user);
await expect(dashboard.welcomeHeading).toBeVisible();
Це не косметика: такий підхід кодує в типах дозволені переходи між сторінками. Компілятор не дасть викликати метод дашборда до того, як ти реально «увійшов». Мінус — жорсткіша зв'язність (coupling): page object логіну тепер знає про існування дашборда. Тому повертати наступну сторінку варто там, де перехід однозначний, і не варто там, де метод може вести в кілька різних станів.
Від сторінок до компонентів
«Одна сторінка — один клас» ламається об реальність сучасного вебу. Хедер, футер, таблиця, модалка, віджет фільтрів повторюються на десятках екранів. Якщо кожен page object містить власні локатори хедера, ти знову дублюєш — просто на рівень вище.
Розв'язання — компонентні об'єкти (component objects): той самий патерн, але одиниця інкапсуляції не сторінка, а шматок UI. Page object при цьому не успадковує компоненти, а містить їх — це композиція (has-a), а не наслідування (is-a). Різниця принципова, і чому саме композиція тут краща — тема глави «Принципи дизайну тестового коду».
Хедер описаний раз і перевикористаний трьома сторінками. Ключ до компонента — скоупити пошук у межах його кореня, а не по всій сторінці, інакше два однакові компоненти на одному екрані конфліктуватимуть:
export class TableComponent {
constructor(private readonly root: Locator) {}
row(text: string): Locator {
return this.root.getByRole('row').filter({ hasText: text });
}
async sortBy(column: string): Promise<void> {
await this.root.getByRole('columnheader', { name: column }).click();
}
}
export class OrdersPage {
readonly orders: TableComponent;
readonly header: HeaderComponent;
constructor(page: Page) {
this.orders = new TableComponent(page.getByTestId('orders-table'));
this.header = new HeaderComponent(page.getByRole('banner'));
}
}
TableComponent приймає свій кореневий Locator і всі пошуки веде від нього (this.root.getByRole(...)). Тому один і той самий клас обслуговує таблицю замовлень, таблицю користувачів і будь-яку іншу — треба лише передати інший корінь. Модалка підтвердження (ConfirmModal), меню хедера, пагінація — усе розкладається так само. Це і є «від класики до компонентів»: класичний POM оперував цілими екранами, компонентний масштабується під сучасні SPA, зібрані з переасамбльованих блоків.
Той самий принцип поза UI: API-клієнти
Найглибша частина відповіді на співбесіді — усвідомлення, що Page Object не про сторінки. Це окремий випадок ширшого патерну: сховати деталі протоколу за фасадом мовою домену. Прибери браузер — і той самий принцип дає API-клієнт.
Тест, який дьорбає ендпоінти напряму, страждає рівно від тих самих бід, що й тест із розсипаними локаторами: URL і форма запиту дублюються, зміна контракту ламає десятки місць. Рішення ізоморфне page object'у — клас, що інкапсулює знання про API:
export class UsersApi {
constructor(private readonly request: APIRequestContext) {}
async create(user: NewUser): Promise<User> {
const res = await this.request.post('/api/users', { data: user });
return res.json();
}
}
UsersApi.create() — це рівно те саме, що LoginPage.login(): метод мовою домену ховає деталь протоколу (там — селектор, тут — URL і метод HTTP). Такий клієнт незамінний для підготовки стану через API замість повільного проходу по UI (згадай про це в главах про тест-дані та піраміду тестування). Механіка HTTP і форматів даних — у главі «REST API та формати даних»; тут важливий сам факт спільного кореня. Побачивши цей зв'язок, ти розумієш і чому Page Object критикують: він змішує в одному класі три ролі (локатори, дії, іноді перевірки), і патерн Screenplay розводить їх (див. «Патерни в автоматизації»).
Типові помилки
Виглядає як зручний page object, а насправді god object. Клас MainPage на 1500 рядків, який знає про хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом — будь-яка зміна UI чіпає цей файл, і два інженери постійно ловлять конфлікти в ньому. Лікування — розбити на компоненти; один клас відповідає за один зв'язний шматок екрана (це принцип єдиної відповідальності, канон — у главі «SOLID у тест-фреймворку»).
Виглядає як інкапсуляція, а насправді протікання. Метод повертає Locator або WebElement назовні, і тест робить .click() уже сам. Формально локатор у класі — але деталь реалізації знову в тесті, і сенс POM втрачено. Page object має віддавати дані й стан, а всі взаємодії тримати всередині.
Виглядає як усунення дублювання, а насправді жорстка ієрархія. BasePage → AuthorizedPage → AdminPage → AdminUsersPage на чотири рівні угору. Глибоке наслідування крихке: зміна в базовому класі непередбачувано зачіпає нащадків. Майже завжди краще передати спільне як компонент (композиція), а не тягнути його вниз спадком.
Виглядає як бізнес-мова, а насправді переклад кліків. Методи названі clickLoginButton(), enterTextInEmailField(). Це той самий імперативний код, лише перенесений в інший файл. Метод page object має описувати намір користувача (login, searchFor, removeFromCart), інакше абстракція нульова.
Перевірки й дії в одному методі. Метод loginAndCheckSuccess() не можна перевикористати в негативному сценарії. Дія і асерт її результату — різні відповідальності; тримай їх окремо.
Підсумок
- Page Object інкапсулює локатори + дії одного екрана за фасадом мовою домену; тест говорить намірами, а не селекторами.
- Головна цінність — єдине джерело правди для локатора: зміна верстки лагодиться в одному місці, а не в кожному тесті.
- Класичний холівар «перевірки в PO» вирішується правилом: page object віддає стан, тест вирішує, що правильне; не змішуй дію і асерт її результату в одному методі.
- Сучасний POM масштабується компонентами через композицію (has-a), а не наслідуванням (is-a) чи god object'ом на всю сторінку.
- Це не патерн про UI: API-клієнт — той самий Page Object, що ховає деталь протоколу за методом мовою домену.
Що питають на співбесіді
- «Що таке Page Object і яку проблему він розв'язує?» Інтерв'юер хоче почути про дублювання і крихкість, а не завчене означення. Сильна відповідь одразу називає «єдине джерело правди для локаторів» і показує, що ти розумієш чому, а не лише що.
- «Чи має page object містити асерти?» Класична пастка на позицію. Правильно — розкрити холівар: показати обидва табори (Фаулер проти, практики за дрібні перевірки-методи) і сформулювати власне робоче правило. Відповідь «ні, ніколи» без пояснення слабша за «залежить, і ось від чого».
- «Як не роздути page object до god object?» Дивляться, чи знаєш ти про компоненти й композицію. Згадай скоупінг пошуку в межах кореня компонента і принцип єдиної відповідальності.
- «Композиція чи наслідування для спільних елементів?» Очікувана відповідь — композиція; треба вміти пояснити, чому глибокі ієрархії
BasePageкрихкі. - «Page Object — лише для UI?» Питання на глибину. Хто бачить, що API-клієнт — той самий патерн, показує системне розуміння, а не заучену механіку.
Джерела
- Playwright — Page Object Models — офіційна рекомендація і приклади на TypeScript.
- Martin Fowler — PageObject — канонічне формулювання патерну і теза «без асертів».
- Selenium — Page object models — класичне трактування, правило «без асертів» і компонентні об'єкти (Page Component Objects).
- CodeceptJS — Page Objects — реалізація патерну в сценарному стилі з актором.
Тема не входить у силабус ISTQB CTFL 4.0 — це інженерна практика автоматизації, а не частина базової теорії тестування; канонічні джерела тут — документація інструментів і першоджерело патерну.
Що таке Page Object і яку проблему він розв'язує?
Page Object (POM) — це клас-фасад, який тримає в собі все знання про один екран: як знайти на ньому елементи і які дії над ним можливі. Розв'язує він дві споріднені біди автоматизації без шару абстракції: дублювання кроків і крихкість тестів. Дублювання виникає, коли сценарій входу чи будь-яка рутина розписані прямо в десятках тестів; крихкість — коли селектор #login-btn протікає в кожен тест, що його згадує, і косметична зміна верстки червонить сюїт без жодного реального бага. Об'єкт сторінки розводить рівні: тест говорить мовою домену (loginPage.login(user)), а не мовою DOM, і локатор живе рівно в одному місці. Сильна відповідь одразу називає ключову цінність — єдине джерело правди для локатора: перейменували кнопку, правиш один рядок в одному класі, і всі тести лагодяться самі.
З яких частин складається класичний page object у мінімальній формі?
Рівно з двох: локатори (де що лежить на екрані) і дії (що з цим можна робити). Локатори описують спосіб пошуку елементів — поля, кнопки, банери; дії — методи, які виконують над ними осмислені операції. Більше в мінімальному page object нічого не потрібно: ні станів, ні бізнес-логіки продукту, ні тестових даних. Усе, що складніше, — це вже або компоненти, які клас у себе включає, або відповідальність, яку варто винести назовні. Розуміння цієї двоскладовості рятує від роздування класу: щойно з'являється спокуса запхати сюди ще й перевірки чи побудову фікстур, це сигнал, що межа патерну порушується.
Чому локатори всередині page object роблять приватними?
Щоб деталь реалізації UI не витекла назад у тести. Уся суть патерну — сховати «як саме дістатися до елемента» за методами «що робимо», тому якщо тест може дотягнутися до самого локатора, інкапсуляція фіктивна. Публічний локатор означає, що завтра хтось напише в тесті page.locator(loginPage.emailField).fill(...), і селектор знову розповзеться по сюїту. Приватність фізично забороняє цей шлях: єдиний спосіб взаємодіяти зі сторінкою — через її методи. Це той самий інкапсуляційний принцип ООП, лише прикладений до тестового коду.
Чому метод clickLoginButton() — поганий, а login() — добрий?
Бо метод page object має описувати намір користувача, а не механіку кліків. clickLoginButton(), enterTextInEmailField() — це той самий імперативний код, лише перенесений в інший файл: абстракція нульова, бо тест усе одно мислить кнопками й полями, а не діями домену. login(user) ховає послідовність (заповнити email, заповнити пароль, натиснути кнопку) за одним осмисленим викликом, і коли форму перероблять — додадуть капчу чи розіб'ють на два кроки — тести не зміняться, бо намір «увійти» лишився тим самим. Практичний критерій: якщо назва методу читається як частина інструкції з DOM, а не як дія користувача, абстракцію не витягнуто.
Чому page object не повинен віддавати назовні Locator чи WebElement?
Бо це та сама протікаюча абстракція, лише замаскована. Формально локатор оголошений усередині класу, але якщо метод повертає його назовні і тест сам робить .click(), деталь реалізації знову опинилась у тесті — і сенс POM втрачено. Правильно page object повертає дані або стан: getErrorText(): Promise<string>, isSubmitEnabled(): Promise<boolean>, а всі взаємодії тримає в собі. Виняток на практиці роблять для web-first assertions Playwright: тонкий геттер може віддати Locator виключно щоб тест зробив expect(locator).toHaveText(...), але це усвідомлений компроміс під ретрайну перевірку, а не дозвіл клікати елементи ззовні.
Чому в Playwright локатори безпечно оголошувати в конструкторі, а в класичному Selenium — ні?
Через різну природу того, що ти зберігаєш. Locator у Playwright лінивий: він не шукає елемент у момент створення, а лише запам'ятовує рецепт пошуку і виконує його щоразу під час дії. Тому оголосити всі локатори в конструкторі можна ще до того, як сторінка завантажилась, — жодної шкоди. У класичному Selenium @FindBy/PageFactory дають WebElement — посилання на конкретний вже знайдений вузол DOM; коли сторінка перемальовується, це посилання «протухає», і будь-яка дія над ним кидає StaleElementReferenceException. Playwright цієї пастки не має за побудовою, бо перешукує елемент під кожну взаємодію. Розуміння цієї різниці пояснює, чому старі гайди наполягали на PageFactory-магії, а сучасні — просто на полях-локаторах.
Чи має page object містити асерти? Розкрий холівар.
Правильна відповідь тут — «залежить», але з поясненням, від чого саме. Табір Мартіна Фаулера каже: page object перевірок не містить, його робота — надати доступ до стану, а рішення «що з цього правильне» належить тесту; так об'єкт лишається перевикористовним і в позитивному, і в негативному сценарії, а expect живе поряд з описом очікуваного результату. Сам Фаулер робить виняток для інваріантів — перевірка, що це взагалі та сторінка і вона коректно завантажилась, у page object доречна. Табір практиків заперечує: винести геть усі асерти означає дублювати прості перевірки в багатьох тестах, тому дрібні assert-методи, що інкапсулюють складне очікування, лишають усередині. Головне правило, яке рятує в обох світах: не змішуй у одному методі дію і перевірку її результату — інакше метод не перевикористаєш там, де очікується протилежне. Відповідь «ні, ніколи» без цього пояснення слабша за «залежить, і ось від чого».
Як web-first assertions Playwright впливають на цей холівар?
Вони роблять його менш болючим. Оскільки expect(locator).toHaveText(...) сам чекає й ретраїть, зникає головна причина ховати ручні очікування всередині page object — раніше туди тягли waitFor-обгортки, бо без них перевірка флакувала. Тепер можна віддати Locator через тонкий геттер лише під асерт і перевіряти прямо в тесті без сирого очікування, лишивши бізнес-рішення там, де йому місце. Тобто інструмент частково знімає компроміс, через який колись і сперечалися табори. Незмінним лишається базове правило про нерозділюваність дії та перевірки в одному методі — це вже не про інструмент, а про перевикористовність.
Що таке fluent-інтерфейс і чому класичний варіант погано лягає на Playwright?
Fluent-інтерфейс — це стиль, коли методи повертають об'єкт, щоб виклики зчіплювались у ланцюжок, який читається майже як речення: loginPage.typeEmail(...).typePassword(...).submit(). Щоб так працювало, кожен метод повертає this, і в синхронному Selenium-світі на Java це популярний прийом. У Playwright він майже не приживається, бо методи асинхронні: вони повертають Promise, тож чистого синхронного зчеплення не буде — довелося б повертати Promise<this> і городити await (await po.typeEmail()).typePassword(), що читається гірше за звичайні окремі await. Тобто проблема не в ідеї, а в тому, що вона конфліктує з асинхронною моделлю мови. Корисніша форма fluent тут інша — повертати не this, а наступний page object з методу навігації.
Навіщо метод навігації повертає наступний page object?
Щоб закодувати в типах дозволені переходи між екранами. Коли login() повертає DashboardPage, компілятор фізично не дасть викликати метод дашборда до того, як ти реально «увійшов» — маршрут користувача перетворюється на ланцюжок, який перевіряється статично. У тесті це читається природно: const dashboard = await new LoginPage(page).login(user) веде прямо до наступного стану. Мінус — жорсткіша зв'язність: page object логіну тепер знає про існування дашборда. Тому повертати наступну сторінку варто лише там, де перехід однозначний, і не варто, коли метод може вести в кілька різних станів — інакше тип збреше про реальність.
Чим компонентний об'єкт відрізняється від page object і навіщо він потрібен?
Це той самий патерн інкапсуляції, але одиниця — не цілий екран, а повторюваний шматок UI: хедер, футер, таблиця, модалка, віджет фільтрів. Правило «одна сторінка — один клас» ламається об сучасний веб, де ці блоки живуть на десятках екранів; без компонентів локатори хедера дублюються в кожному page object, тобто дублювання просто піднімається на рівень вище. Компонентний об'єкт описує блок один раз, а сторінки його включають. Ключовий нюанс реалізації — компонент приймає свій кореневий Locator і всі пошуки веде від нього, а не по всій сторінці, тому один клас TableComponent обслуговує і таблицю замовлень, і таблицю користувачів: треба лише передати інший корінь. Це і є «від класики до компонентів» — класичний POM оперував екранами, компонентний масштабується під SPA, зібрані з переасамбльованих блоків.
Чому пошук усередині компонента треба скоупити в межах його кореня?
Бо інакше два однакові компоненти на одному екрані конфліктуватимуть. Якщо TableComponent шукає рядок через page.getByRole('row'), а на сторінці дві таблиці, локатор поверне рядки з обох і перевірка стане неоднозначною або впаде на strict-mode. Коли ж компонент веде пошук від переданого кореня (this.root.getByRole('row')), він фізично не бачить нічого поза своїм піддеревом. Саме скоупінг робить компонент по-справжньому перевикористовним: клас не прив'язаний до жодного конкретного місця на сторінці, а працює з будь-яким коренем, який йому дали. Без цього компонентний підхід розсипається на першому ж екрані з двома схожими блоками.
Композиція чи наслідування для спільних елементів, і чому?
Композиція. Спільний хедер чи таблицю page object має містити (has-a), а не успадковувати (is-a): він тримає компонент як поле, а не тягне його вниз ланцюжком спадку. Глибокі ієрархії на кшталт BasePage → AuthorizedPage → AdminPage → AdminUsersPage крихкі, бо зміна в базовому класі непередбачувано зачіпає всіх нащадків, а перевикористати шматок логіки з середини такого ланцюга майже неможливо. Композиція ж дозволяє зібрати сторінку з незалежних блоків і перевикористати кожен окремо, де він реально потрібен. Тому на питання «як прибрати дублювання спільних елементів» очікувана відповідь — винести їх у компонент і включати, а не будувати вертикаль наслідування.
Що таке god object у контексті POM і як його уникнути?
Це page object, який розрісся до класу на півтори тисячі рядків і знає про все одразу — хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом упізнаваний: будь-яка зміна UI чіпає цей файл, і кілька інженерів постійно ловлять у ньому конфлікти при мерджі. Причина — порушений принцип єдиної відповідальності: один клас відповідає за десяток незв'язних речей. Лікування — розбити його на компоненти, де кожен клас тримає рівно один зв'язний шматок екрана. Це прямо перегукується з переходом «від сторінок до компонентів»: god object майже завжди означає, що повторювані блоки не винесли в окремі об'єкти.
Page Object — це патерн лише про UI?
Ні, і це найглибша частина відповіді на співбесіді. Page Object — окремий випадок ширшого принципу: сховати деталі протоколу за фасадом мовою домену. Прибери браузер — і той самий підхід дає API-клієнт: клас, що інкапсулює знання про ендпоінти, де UsersApi.create() — це рівно те саме, що LoginPage.login(), лише ховає не селектор, а URL і метод HTTP. Тест, який дьорбає ендпоінти напряму, страждає від тих самих бід, що й тест із розсипаними локаторами: форма запиту дублюється, зміна контракту ламає десятки місць. Такий клієнт ще й незамінний для швидкої підготовки стану через API замість повільного проходу по UI. Хто бачить цей спільний корінь, розуміє і критику POM — що він змішує в одному класі кілька ролей (локатори, дії, іноді перевірки), які патерн Screenplay розводить окремо.
Три кейси, які показують POM у роботі: як рефакторинг у page object прибирає крихкість, як один компонент обслуговує дві однакові таблиці на екрані завдяки скоупінгу, і як той самий фасад працює без браузера — в API-клієнті для підготовки стану. Скрізь — що саме дивитися і чому.
Кейс 1. Рефакторинг: із розсипаних локаторів у фасад
Стартова точка — типовий тест без жодної абстракції над сторінкою. Логін розписаний прямо в кроках, а селектори вшиті в перевірку.
test('порожній кошик показує заглушку', async ({ page }) => {
await page.goto('/login');
await page.locator('#email').fill('qa@example.com');
await page.locator('#password').fill('secret');
await page.locator('#login-btn').click();
await page.goto('/cart');
await expect(page.locator('.cart-empty')).toBeVisible();
});
Тепер уяви ще двадцять тестів із тим самим початком. Розробники перейменували #login-btn на #signin-btn — і червоніє двадцять один тест, хоча продукт працює. Виносимо знання про екран у клас:
export class LoginPage {
private readonly email: Locator;
private readonly password: Locator;
private readonly submit: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Пароль');
this.submit = page.getByRole('button', { name: 'Увійти' });
}
async open(): Promise<void> {
await this.page.goto('/login');
}
async login(user: { email: string; password: string }): Promise<void> {
await this.email.fill(user.email);
await this.password.fill(user.password);
await this.submit.click();
}
}
Тест після рефакторингу говорить мовою наміру, а не DOM:
test('порожній кошик показує заглушку', async ({ page }) => {
const login = new LoginPage(page);
await login.open();
await login.login({ email: 'qa@example.com', password: 'secret' });
await expect(page.getByTestId('cart-empty')).toBeVisible();
});
Що дивитися і чому:
- Селектор кнопки тепер живе рівно в одному місці. Те саме перейменування правиться одним рядком у класі — двадцять один тест лагодиться сам. Це і є єдине джерело правди, заради якого існує патерн.
- Локатори
private. Тест фізично не може дотягнутися доsubmitі клікнути його напряму, тож деталь реалізації не витікає назад у сюїт. Публічний локатор звів би всю вигоду нанівець. login()описує намір, а не кроки. Додадуть у форму капчу чи розіб'ють її на два екрани — сигнатура методу не зміниться, бо намір «увійти» лишився. МетодclickLoginButton()тут був би тим самим імперативним кодом, лише в іншому файлі.- Локатори в конструкторі безпечні.
getByLabelповертає лінивийLocator— рецепт пошуку, а не знайдений вузол; він виконається під часfill, а не в момент створення. У Selenium аналогічний@FindByдав биWebElement, який протух би при перемальовуванні сторінки зStaleElementReferenceException.
Кейс 2. Компонент, який обслуговує дві таблиці на одному екрані
На дашборді дві однакові таблиці — активні замовлення і архів. Спокуса написати два майже ідентичні page object або дублювати локатори рядків. Замість цього виносимо таблицю в компонент, який веде пошук від свого кореня.
export class TableComponent {
constructor(private readonly root: Locator) {}
row(text: string): Locator {
return this.root.getByRole('row').filter({ hasText: text });
}
async sortBy(column: string): Promise<void> {
await this.root.getByRole('columnheader', { name: column }).click();
}
async rowCount(): Promise<number> {
return this.root.getByRole('row').count();
}
}
export class DashboardPage {
readonly active: TableComponent;
readonly archive: TableComponent;
constructor(page: Page) {
this.active = new TableComponent(page.getByTestId('active-orders'));
this.archive = new TableComponent(page.getByTestId('archived-orders'));
}
}
У тесті обидві таблиці — це той самий клас із різним коренем:
test('скасоване замовлення переїжджає в архів', async ({ page }) => {
const dash = new DashboardPage(page);
await dash.active.row('Замовлення #42').getByRole('button', { name: 'Скасувати' }).click();
await expect(dash.active.row('Замовлення #42')).toHaveCount(0);
await expect(dash.archive.row('Замовлення #42')).toBeVisible();
});
Що дивитися і чому:
- Скоупінг у межах кореня — ключ до перевикористання. Якби
row()шукав черезpage.getByRole('row'), він повертав би рядки з обох таблиць одразу, іtoHaveCount(0)став би неоднозначним чи впав на strict-mode. Пошукthis.root.getByRole('row')бачить лише піддерево свого кореня. - Один клас — обидві таблиці.
TableComponentне прив'язаний до конкретного місця; передаси йому коріньactive-ordersчиarchived-orders— і він працює. Той самий клас обслужив би й таблицю користувачів на іншому екрані. - Це композиція, а не наслідування.
DashboardPageмістить дві таблиці як поля (has-a), а не успадковує їх. Спроба зашити спільну таблицю черезBasePageдала б жорстку ієрархію, з якої два незалежні екземпляри на одному екрані витягти неможливо. - Компонент віддає
Locatorпід асерт, але не клікає ззовні за тест.row()повертає локатор навмисно — щоб тест зробив web-firstexpect(...).toBeVisible()з автоочікуванням. Дії (sortBy) лишаються методами всередині.
Кейс 3. Той самий фасад без браузера: API-клієнт для підготовки стану
UI-тест «користувач бачить свої замовлення» не повинен клікати реєстрацію й ручне створення замовлення — це повільно й крихко. Стан готуємо через API тим самим патерном: клас, що ховає протокол за методом мовою домену.
export class UsersApi {
constructor(private readonly request: APIRequestContext) {}
async create(user: NewUser): Promise<User> {
const res = await this.request.post('/api/users', { data: user });
expect(res.status(), 'фікстура користувача не створилась').toBe(201);
return res.json();
}
}
export class OrdersApi {
constructor(private readonly request: APIRequestContext) {}
async placeFor(userId: string, item: string): Promise<Order> {
const res = await this.request.post('/api/orders', { data: { userId, item } });
expect(res.status()).toBe(201);
return res.json();
}
}
Тест готує дані по API, а перевіряє через UI:
test('користувач бачить своє замовлення в списку', async ({ page, request }) => {
const user = await new UsersApi(request).create({ email: 'qa+42@example.com', password: 'secret' });
const order = await new OrdersApi(request).placeFor(user.id, 'Клавіатура');
const login = new LoginPage(page);
await login.open();
await login.login({ email: user.email, password: 'secret' });
await expect(page.getByTestId('orders-list')).toContainText(order.item);
});
Що дивитися і чому:
UsersApi.create()— це той самийLoginPage.login(). Обидва ховають деталь протоколу за методом домену: там — селектор, тут — URL і метод HTTP. Зміниться контракт ендпоінта — правиш один клас, а не десятки тестів, що дьорбали/api/usersнапряму. Це доводить, що Page Object не про UI, а про фасад над протоколом.- Підготовка стану через API швидша й стабільніша за UI. Прохід реєстрації кліками додав би секунди й точку флаку до кожного тесту, який лише потребує залогіненого користувача з замовленням.
- Асерт статусу тут — це перевірка інваріанта фікстури, а не бізнес-сценарію.
expect(res.status()).toBe(201)усередині клієнта каже «стан справді підготувався»; це той самий дозволений виняток, що й перевірка «чи це та сторінка» у класичному page object. Бізнес-перевірка (замовлення видно в списку) лишається в тесті. - Метод повертає дані, а не сирий відгук.
create()віддає розпарсенийUser, тож тест оперуєuser.id, а неres. Якби клієнт повертав назовніAPIResponse, деталь протоколу знову витекла б у тест — та сама протікаюча абстракція, що й поверненийLocator.
Навіщо POM і що це таке
- Можу назвати дві біди, які лікує page object: дублювання кроків і крихкість тестів через розсипані по сюїту локатори.
- Розумію головну цінність — єдине джерело правди для локатора: зміна верстки правиться в одному класі, а не в кожному тесті.
- Знаю, що page object говорить мовою домену (
login(user)), а тест не мислить селекторами й DOM.
Анатомія класичного page object
- Знаю, що мінімальний page object — це рівно локатори + дії, і нічого зайвого (без тест-даних і бізнес-логіки продукту).
- Розумію, чому локатори роблять
private: інакше деталь реалізації протікає назад у тести. - Можу пояснити, чому метод має описувати намір (
login), а не механіку (clickLoginButton). - Знаю правило: page object віддає дані або стан (
getErrorText), а не сирийLocator/WebElement.
Playwright vs Selenium
- Розумію, що
Locatorу Playwright лінивий (зберігає рецепт пошуку), тому в конструкторі безпечно оголошувати локатори ще до завантаження сторінки. - Знаю природу
StaleElementReferenceExceptionу Selenium (@FindBy/WebElement) і чому лінивий локатор Playwright її не має.
Холівар про перевірки
- Знаю позицію Фаулера: page object без асертів, він віддає стан — тест вирішує, що правильне; виняток — перевірка інваріантів сторінки.
- Знаю контраргумент практиків: дрібні
assert-методи всередині проти дублювання простих перевірок у багатьох тестах. - Розумію головне правило: не змішувати дію і перевірку її результату в одному методі (
loginAndCheckSuccess— антипатерн); web-first assertions послаблюють спокусу ховати очікування всередині.
Fluent та навігація
- Розумію, чому класичний fluent через
thisпогано лягає на асинхронний Playwright (Promise<this>читається гірше за окреміawait). - Знаю корисну форму fluent — повертати наступний page object з методу навігації (кодує дозволені переходи в типах); мінус — жорсткіша зв'язність.
Від сторінок до компонентів
- Розумію, чому «одна сторінка — один клас» ламається: хедер, таблиця, модалка повторюються на десятках екранів.
- Знаю різницю композиція (has-a) vs наслідування (is-a) і чому глибокі ієрархії
BasePage→AdminUsersPageкрихкі. - Можу пояснити, навіщо компонент скоупить пошук у межах кореневого
Locator, а не по всій сторінці. - Впізнаю god object (клас на 1500 рядків, конфлікти в мерджі) і лікую його розбиттям на компоненти за принципом єдиної відповідальності.
Той самий принцип поза UI
- Можу пояснити, що API-клієнт — той самий Page Object:
UsersApi.create()ховає URL і метод HTTP так само, якlogin()ховає селектор; корисний для швидкої підготовки стану через API. - Розумію критику POM (змішує локатори, дії, іноді перевірки) і що патерн Screenplay розводить ці ролі.
Яку головну цінність дає Page Object при зміні верстки?
Питання
Яку проблему розв'язує Page Object?