Page Object: від класики до компонентів
Зміст
Уяви із двохсот UI-тестів, у кожному з яких десь усередині написано page.locator('#login-btn').click(). Розробники перейменували кнопку на #signin-btn — і ти правиш той самий рядок у двохстах місцях, ризикуючи пропустити десяток. Це не гіпотетичний жах: саме так виглядає автоматизація без жодного шару абстракції над сторінкою. (locator) розповзаються по тестах, кроки дублюються, а будь-яка косметична зміна фронтенду перетворює зелену сюїту на червону не через баг, а через крихкість самих тестів.
Model (POM) — найстаріша і найпоширеніша відповідь індустрії на цю проблему. Безальтернативною її не назвеш: у частини канонічних док позиція протилежна, і про це нижче. Але справжня перевірка не в тому, чи знаєш ти означення, а чи розумієш межі патерну: де він рятує, де перетворюється на , і чому та сама ідея працює далеко за межами браузера. Ця глава — про механізм, а не про заучену формулу.
Проблема, яку розв'язує POM
Тест має відповідати на питання «що перевіряємо», а не «як саме дістатися до елемента». Коли ці два рівні змішані в одному файлі, ти отримуєш дві біди одразу: дублювання і крихкість.
Дублювання. Логін повторюється на початку десятків тестів. Якщо сценарій входу описаний прямо в тесті — заповнити поле email, заповнити поле пароля, натиснути кнопку — то він скопійований усюди, де потрібен залогінений користувач.
Крихкість. Локатор #login-btn — це деталь реалізації UI. Коли верстка змінюється, деталь протікає в кожен тест, який її згадує. Тест ламається не тому, що продукт зламався, а тому, що ми прив'язали перевірку бізнес-логіки до випадкового селектора.
POM розводить ці рівні. Об'єкт сторінки (page object) — це клас, який інкапсулює все знання про конкретний екран: як знайти на ньому елементи і які дії над ним доступні. Тест працює з методами цього класу мовою домену (loginPage.login(user)), а не мовою DOM. Локатор кнопки живе рівно в одному місці. Перейменували кнопку — правиш один рядок в одному класі, і всі двісті тестів лагодяться самі.
Ключове слово тут — єдине джерело істини (single source of truth). Це прямий наслідок принципу (детальніше — у главі «Принципи дизайну тестового коду») і того самого ООП-мислення, з якого ми почали розділ (див. «ООП на тестовому коді»): клас ховає складність за зрозумілим інтерфейсом.
Сторінка = локатори + дії
Класичний 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, щоб деталь реалізації не протікала назовні, — але це наша конвенція, а не вимога джерела: офіційний приклад Playwright, навпаки, оголошує локатори публічними readonly і дає тесту звертатися до них напряму. По-друге, дії названі мовою користувача (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 поля, ми вважаємо протікаючою абстракцією. Але чесно: джерельної опори в цього правила немає. Офіційний приклад Playwright і імпортує expect у файл page object, і перевіряє публічний локатор прямо в тесті — тож це наша домовленість, а не галузевий консенсус.
Перевірки в page object: холівар
Ось найгарячіша суперечка навколо POM, і на співбесіді відповідь «залежить» тут не відмазка, а правильна позиція — якщо ти можеш пояснити, від чого саме залежить. Пояснення коротке: три канонічні для теми джерела дають три різні норми, і різняться вони не деталями, а модальністю — тим, з якою силою кожне говорить. Одне висловлює позицію, друге приписує, третє просто робить.
Фаулер — власна позиція автора. Мартін Фаулер спершу чесно наводить аргументи обох сторін, а свій висновок формулює від першої особи: «I favor having no assertions in page objects» — я віддаю перевагу, а не так треба. Логіка: робота об'єкта — надати доступ до стану сторінки, а вирішувати, що з цього стану правильне, — робота тесту; так об'єкт лишається перевикористовним і в позитивному, і в негативному сценарії, а expect живе поряд з описом очікуваного результату. Виняток він теж називає від першої особи — перевірка інваріантів (чи це взагалі та сторінка і чи вона коректно завантажилась) доречна навіть у «безасертному» стилі, на відміну від перевірок конкретного сценарію.
// 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('Невірні дані');
});
Selenium — нормативний припис із одним обов'язковим винятком. Офіційна дока Selenium говорить зовсім іншою модальністю: «Page objects themselves should never make verifications or assertions» — ніколи, і це вимога, а не перевага. Але тут же вона вводить рівно один виняток, і він теж нормативний: є «one, single, verification which can, and should, be within the page object» — перевірка, що сторінка і критичні елементи на ній завантажилися коректно, причому зробити її треба під час створення об'єкта. Тобто інваріант із виноски Фаулера тут стає обов'язком. Підсумковий список правил у тій самій доці, до речі, м'якшає до «Generally don’t make assertions» — «загалом».
Playwright — практика в офіційному прикладі, без жодного припису. Дока Playwright норми про не формулює взагалі: вона показує кодом протилежне. В офіційному прикладі expect імпортовано просто у файл page object (import { expect, type Locator, type Page } from '@playwright/test'), перевірка стоїть усередині методу об'єкта (await expect(this.gettingStartedHeader).toBeVisible()), а локатори оголошені публічними readonly, тож тест перевіряє їх напряму: await expect(playwrightDev.tocList).toHaveText([...]). Це важливіше за «джерело мовчить»: канон однієї з двох найпоширеніших док робить рівно те, що канон іншої забороняє.
Контраргумент практиків, який стоїть за цією практикою. Винести назовні геть усі перевірки означає, що прості асерти дублюються в багатьох тестах. Компроміс — виносити в page object дрібні assert-методи, які інкапсулюють складне очікування (наприклад, «дочекатися, поки таблиця перемалюється, і переконатися, що рядок з'явився»), але тримати бізнес-перевірки в тестах. Межа тут названа: це аргумент практики, підпертий офіційним прикладом Playwright, а не нормативне «так треба» — саме тому він не скасовує припису Selenium, а стоїть поруч із ним.
Практичний орієнтир, який рятує в обох світах: web-first assertions Playwright роблять цей холівар менш болючим. Оскільки expect(locator).toHaveText(...) сам чекає й ретраїть, спокуса ховати ручні очікування всередині page object слабшає — можна віддати Locator через тонкий геттер лише для асерту й перевіряти в тесті без сирого waitFor. Наше робоче правило, яке не залежить від того, чий канон ти обрав: не змішуй у методі і дію, і перевірку її результату — інакше метод не перевикористаєш у сценарії, де очікується протилежне. Це наша конвенція, а не цитата з джерела: жодне з трьох її саме так не формулює, але вона сумісна з усіма трьома. Це прямо перегукується з принципом атомарності з глави «Анатомія автотесту».
Табір «page object'и не шарити». Є й окрема позиція збоку, про яку часто забувають, — вона не про місце асерту, а про сам спільний об'єкт. Дока Cypress прямо називає спільні page object'и антипатерном (Anti-Pattern: Sharing page objects) — разом із входом через UI і небажанням «зрізати кути». Як Best Practice там радять інше: тестувати специфікації ізольовано, логінитися програмно й керувати станом застосунку напряму. Логіка та сама, що в главі про тест-дані: якщо стан можна підготувати через API, довгий прохід по UI через спільний об'єкт сторінки додає крихкості, а не знімає її. POM це не скасовує, але й безальтернативним не лишає.
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), меню хедера, — усе розкладається так само. Це і є «від класики до компонентів» — з важливим уточненням: компонентність не еволюція патерна, а його первісне формулювання. Першоджерело з самого початку радить будувати об'єкти не на кожну сторінку, а на значущі елементи сторінки, і навіть визнає назву оманливою — «panel object» пасувало б краще. «Одна сторінка — один клас» — це спрощення, яке додала практика, а не позиція канонічного опису.
Той самий принцип поза 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 розводить їх (див. «Патерни в автоматизації»).
Тема не входить у силабус ISTQB CTFL 4.0 — це інженерна практика автоматизації, а не частина базової теорії тестування; канонічні джерела тут — документація інструментів і першоджерело патерну, а з боку тестування підставу дає CTAL-TAE.
Типові помилки
Виглядає як зручний page object, а насправді god object. Термін не наш жаргон, а усталене поняття ООП: обʼєкт, що «references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both», і джерело прямо класифікує його як «an example of an anti-pattern and a code smell». Порогів у рядках означення не задає — ознаки якісні, тож наступні числа читайте як ілюстрацію, а не як критерій. На практиці це клас MainPage на 1500 рядків, який знає про хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом — будь-яка зміна UI чіпає цей файл, і два інженери постійно ловлять конфлікти в ньому. Лікування — розбити на компоненти; один клас відповідає за один зв'язний шматок екрана (це принцип єдиної відповідальності, канон — у главі «SOLID у тест-фреймворку»).
Виглядає як інкапсуляція, а насправді протікання — за нашою конвенцією. Метод повертає Locator або WebElement назовні, і тест робить .click() уже сам. Формально локатор у класі — але деталь реалізації знову в тесті. За нашим правилом page object має віддавати дані й стан, а всі взаємодії тримати всередині; офіційний приклад Playwright цього правила не тримає, тож це домовленість команди, а не універсальна норма.
Виглядає як усунення дублювання, а насправді жорстка ієрархія. BasePage → AuthorizedPage → AdminPage → AdminUsersPage на чотири рівні угору. Глибоке успадкування крихке: зміна в базовому класі непередбачувано зачіпає нащадків. Майже завжди краще передати спільне як компонент (композиція), а не тягнути його вниз спадком.
Виглядає як бізнес-мова, а насправді переклад кліків. Методи названі clickLoginButton(), enterTextInEmailField(). Це той самий імперативний код, лише перенесений в інший файл. Метод page object має описувати намір користувача (login, searchFor, removeFromCart), інакше абстракція нульова.
Перевірки й дії в одному методі. Метод loginAndCheckSuccess() не можна перевикористати в негативному сценарії. Дія і асерт її результату — різні відповідальності; тримай їх окремо.
Підсумок
- Page Object інкапсулює локатори + дії одного екрана за фасадом мовою домену; тест говорить намірами, а не селекторами.
- Головна цінність — єдине джерело істини для локатора: зміна верстки лагодиться в одному місці, а не в кожному тесті.
- Класичний холівар «перевірки в PO» єдиного припису не має: у Фаулера це власна позиція («I favor»), у Selenium — нормативне
should neverз одним обов'язковим винятком на перевірку завантаження, у Playwright — офіційний приклад, який робить протилежне. Наша конвенція поверх усіх трьох: не змішуй дію і асерт її результату в одному методі. - Сучасний POM масштабується компонентами через композицію (has-a), а не успадкуванням (is-a) чи god object'ом на всю сторінку.
- Це не патерн про UI: API-клієнт — той самий Page Object, що ховає деталь протоколу за методом мовою домену.
Можливі питання
- «Що таке Page Object і яку проблему він розв'язує?» Інтерв'юер хоче почути про дублювання і крихкість, а не завчене означення. Сильна відповідь одразу називає «єдине джерело істини для локаторів» і показує, що ти розумієш чому, а не лише що.
- «Чи має page object містити асерти?» Класична пастка на позицію, і сильна відповідь називає не висновки, а модальність джерел: у Фаулера це власна позиція автора, у Selenium — нормативне
should neverз одним обов'язковим винятком, у Playwright — офіційний приклад, який кладеexpectусередину об'єкта. Далі формулюєш власне робоче правило. Відповідь «ні, ніколи» слабка не через категоричність, а тому, що приписує одному джерелу силу, якої воно не має, і мовчить про два інші — а інтерв'юер міг читати саме їх. - «Як не роздути page object до god object?» Дивляться, чи знаєш ти про компоненти й композицію. Згадай пошуку в межах кореня компонента і принцип єдиної відповідальності.
- «Композиція чи успадкування для спільних елементів?» Очікувана відповідь — композиція; треба вміти пояснити, чому глибокі ієрархії
BasePageкрихкі. - «Page Object — лише для UI?» Питання на глибину. Хто бачить, що API-клієнт — той самий патерн, показує системне розуміння, а не заучену механіку.
Джерела
- Martin Fowler — PageObject — сама постановка проблеми: тести, що напряму торкають HTML, крихкі до змін UI, а page object обгортає сторінку застосунковим API.
- Selenium — Page object models — дві названі вигоди (чисте розділення тестового коду й коду сторінки, єдине сховище операцій) і наскрізний інваріант: рівно одне місце в сюїті знає структуру HTML цієї частини сторінки.
- ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: цінність сформульована як «updates in only one place, the locator inside a page model».
- Playwright — Page Object Models — офіційний приклад на TypeScript: клас інкапсулює операції сторінки, локатори оголошено публічними
readonly, іexpectімпортовано просто у файл page object. - Martin Fowler — PageObject — критерій правильної межі — тест на заміну контрола: якщо конкретний віджет замінили, інтерфейс page object змінюватися не повинен; операції мають повертати фундаментальні типи або інші page object.
- Playwright — Locators — чому оголошувати локатори в конструкторі безпечно: локатор це спосіб знайти елемент у будь-який момент, а не вже знайдений елемент.
Перевірки в page object: холівар
- Selenium — Page object models — нормативний бік холівару: «Page objects themselves should never make verifications or assertions», з єдиним дозволеним винятком — перевіркою завантаження сторінки при створенні обʼєкта.
- Martin Fowler — PageObject — авторська позиція, а не припис: «I favor having no assertions in page objects», і тут же названий виняток на перевірки інваріантів сторінки.
- Playwright — Page Object Models — третя модальність: жодного припису про асерти, зате сам приклад тримає
expectу файлі page object. - Cypress — Best Practices — четвертий, окремий табір: «Anti-Pattern: Sharing page objects, using your UI to log in, and not taking shortcuts» проти «Test specs in isolation, programmatically log into your application, and take control of your applicationʼs state».
- Playwright — Test assertions — чому холівар менш болючий: web-first асерти самі чекають і повторюють, тож ручні очікування ховати в page object не треба.
- Martin Fowler — PageObject — джерело самої конструкції: операції page object повертають фундаментальні типи або інші page object, а різні результати тієї самої дії моделюють різними методами.
- Selenium — Page object models — те саме правило з боку класики: «Different results for the same action are modelled as different methods».
- Martin Fowler — PageObject — обʼєкти будують на значущі елементи сторінки, а не на сторінку; ієрархія, що існує лише для структурування верстки, в обʼєкти проступати не повинна; автор сам визнає назву оманливою — точнішою була б «panel object».
- Selenium — Page object models — та сама ідея під власною назвою Page Component Objects: застосунки будують не з монолітної сторінки, а з компонентів у ній.
- CodeceptJS — Page Objects — реалізація в сценарному стилі: технічно тотожний page object, різниця лише концептуальна.
Той самий принцип поза UI: API-клієнти
- Martin Fowler — PageObject — узагальнення, з якого росте API-клієнт: обгортка дає програмному клієнту робити й бачити те саме, що людина, ховаючи деталі носія.
- Serenity/JS — Screenplay Pattern — критика, названа поіменно: Screenplay розводить ролі, які page object тримає в одному класі.
- ISTQB® CTAL-TAE Syllabus v2.0 — чому це взагалі питання дизайну: автоматизація тестування — діяльність із розробки ПЗ, тож патерни в ній ті самі, що в продуктовому коді.
- Wikipedia — God object — джерело цитованого означення: обʼєкт, що «references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both», класифікований як «an example of an anti-pattern and a code smell» (
trust: secondary, dec-0710). - PMD — Design rules (God Class) — та сама вада як правило , і головне уточнення: ознаки метричні (складність, звертання до чужих даних, низька звʼязність методів), а не «скільки рядків у файлі».
Відео до теми
PAGE OBJECT MODEL теорія + практика C# Selenium WebDriverПопелюха 👾 Тестування ПЗ
PageObject - просто про головний патерн автоматизації | POM Playwright Typescriptqa senpai | test automation
композиція - наступний рівень page object model playwrightqa senpai | test automation
фікстури playwright і керування page object modelqa senpai | test automation
імперативний VS декларативний стиль - свідомий дизайн page object model і тестівqa senpai | test automationЩо таке 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?
Бо це та сама протікаюча абстракція, лише замаскована. Одразу застереження: джерельної опори в цього правила немає — офіційний приклад Playwright тримає локатори публічними й перевіряє їх прямо в тесті, тож нижче наша домовленість, а не галузева норма. Формально локатор оголошений усередині класу, але якщо метод повертає його назовні і тест сам робить .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 містити асерти? Розкрий холівар.
Правильна відповідь тут — «залежить», але з поясненням, від чого саме: три канонічні для теми джерела дають три різні норми, і різняться вони модальністю — силою, з якою кожне говорить. Мартін Фаулер — власна позиція автора: він наводить аргументи обох сторін і формулює висновок від першої особи («I favor having no assertions in page objects»), тобто це перевага, а не припис; робота об'єкта — надати доступ до стану, а рішення «що з цього правильне» належить тесту, і так об'єкт лишається перевикористовним і в позитивному, і в негативному сценарії. Виняток він теж називає за себе — перевірка інваріантів (чи це взагалі та сторінка і чи вона завантажилась) доречна. Selenium — нормативний припис: «Page objects themselves should never make verifications or assertions», і тут же рівно один виняток, теж обов'язковий (can, and should) — перевірити, що сторінка й критичні елементи завантажилися, причому під час створення об'єкта. Playwright — практика в офіційному прикладі: припису немає взагалі, зате приклад у доці імпортує expect у файл page object, ставить перевірку всередину методу і тримає локатори публічними, щоб тест перевіряв їх напряму. Саме на цю практику спирається контраргумент практиків: винести геть усі означає дублювати прості перевірки в багатьох тестах, тому дрібні assert-методи, що інкапсулюють складне очікування, лишають усередині. Окремо стоїть Cypress: його дока називає спільні page object'и антипатерном і радить ізольовані специфікації, програмний логін і керування станом застосунку — це вже не про місце асерту, а про сам шаринг об'єкта. Наше робоче правило поверх усіх трьох (це конвенція, не цитата): не змішуй у одному методі дію і перевірку її результату — інакше метод не перевикористаєш там, де очікується протилежне. Відповідь «ні, ніколи» слабка не через категоричність, а тому, що приписує одному джерелу силу, якої воно не має, і мовчить про два інші.
Як 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 обслуговує і таблицю замовлень, і таблицю користувачів: треба лише передати інший корінь. Це і є «від класики до компонентів» — з поправкою, що компонентність не еволюція патерна: першоджерело від початку радить будувати об'єкти на значущі елементи сторінки, а не на кожну сторінку, і навіть визнає назву «page» оманливою. «Одна сторінка — один клас» додала практика.
Чому скоуп пошуку всередині компонента треба звужувати до його кореня?
Бо інакше два однакові компоненти на одному екрані конфліктуватимуть. Якщо TableComponent шукає рядок через page.getByRole('row'), а на сторінці дві таблиці, локатор поверне рядки з обох і перевірка стане неоднозначною або впаде на strict-mode. Коли ж компонент веде пошук від переданого кореня (this.root.getByRole('row')), він фізично не бачить нічого поза своїм піддеревом. Саме робить компонент по-справжньому перевикористовним: клас не прив'язаний до жодного конкретного місця на сторінці, а працює з будь-яким коренем, який йому дали. Без цього компонентний підхід розсипається на першому ж екрані з двома схожими блоками.
Композиція чи успадкування для спільних елементів, і чому?
Композиція. Спільний хедер чи таблицю page object має містити (has-a), а не успадковувати (is-a): він тримає компонент як поле, а не тягне його вниз ланцюжком спадку. Глибокі ієрархії на кшталт BasePage → AuthorizedPage → AdminPage → AdminUsersPage крихкі, бо зміна в базовому класі непередбачувано зачіпає всіх нащадків, а перевикористати шматок логіки з середини такого ланцюга майже неможливо. Композиція ж дозволяє зібрати сторінку з незалежних блоків і перевикористати кожен окремо, де він реально потрібен. Тому на питання «як прибрати дублювання спільних елементів» очікувана відповідь — винести їх у компонент і включати, а не будувати вертикаль успадкування.
Що таке god object у контексті POM і як його уникнути?
god object — усталений антипатерн ООП: обʼєкт із посиланнями на велику кількість різних типів і/або надто багатьма незвʼязаними методами, класифікований як «an example of an anti-pattern and a code smell». Порогів у рядках означення не дає, тож числа далі — ілюстрація. У POM це page object, який розрісся до класу на півтори тисячі рядків і знає про все одразу — хедер, футер, три модалки, таблицю й половину бізнес-логіки. Симптом упізнаваний: будь-яка зміна UI чіпає цей файл, і кілька інженерів постійно ловлять у ньому конфлікти при . Причина — порушений принцип єдиної відповідальності: один клас відповідає за десяток незв'язних речей. Лікування — розбити його на компоненти, де кожен клас тримає рівно один зв'язний шматок екрана. Це прямо перегукується з переходом «від сторінок до компонентів»: майже завжди означає, що повторювані блоки не винесли в окремі об'єкти.
Page Object — це патерн лише про UI?
Ні, і це найглибша частина відповіді на співбесіді. Page Object — окремий випадок ширшого принципу: сховати деталі протоколу за фасадом мовою домену. Прибери браузер — і той самий підхід дає API-клієнт: клас, що інкапсулює знання про , де UsersApi.create() — це рівно те саме, що LoginPage.login(), лише ховає не селектор, а URL і метод HTTP. Тест, який дьорбає ендпоінти напряму, страждає від тих самих бід, що й тест із розсипаними локаторами: форма запиту дублюється, зміна контракту ламає десятки місць. Такий клієнт ще й незамінний для швидкої підготовки стану через API замість повільного проходу по UI. Хто бачить цей спільний корінь, розуміє і критику POM — що він змішує в одному класі кілька ролей (локатори, дії, іноді перевірки), які патерн Screenplay розводить окремо.
Три кейси, які показують POM у роботі: як рефакторинг у прибирає крихкість, як один компонент обслуговує дві однакові таблиці на екрані завдяки , і як той самий фасад працює без браузера — в 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і клікнути його напряму, тож деталь реалізації не витікає назад у . Офіційний приклад Playwright робить інакше — тримає локатори публічнимиreadonly, — тож це домовленість команди, а не вимога джерела. 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 говорить мовою домену (
login(user)), а тест не мислить селекторами й DOM.
Анатомія класичного page object
- Знаю, що мінімальний page object — це рівно локатори + дії, і нічого зайвого (без тест-даних і бізнес-логіки продукту).
- Розумію, чому наша конвенція тримає локатори
private: інакше деталь реалізації протікає назад у тести — і знаю, що офіційний приклад Playwright оголошує їх публічними. - Можу пояснити, чому метод має описувати намір (
login), а не механіку (clickLoginButton). - Знаю наше правило: page object віддає дані або стан (
getErrorText), а не сирийLocator/WebElement; джерельної опори в нього немає.
Playwright vs Selenium
- Розумію, що
Locatorу Playwright лінивий (зберігає рецепт пошуку), тому в конструкторі безпечно оголошувати локатори ще до завантаження сторінки. - Знаю природу
StaleElementReferenceExceptionу Selenium (@FindBy/WebElement) і чому лінивий локатор Playwright її не має.
Холівар про перевірки
- Розумію, що єдиного припису тут немає, і вмію назвати модальність кожного з трьох джерел, а не лише його висновок.
- Знаю позицію Фаулера і те, що це власна позиція автора («I favor»), а не норма: page object віддає стан — тест вирішує, що правильне; виняток — перевірка інваріантів сторінки.
- Знаю припис Selenium і те, що він нормативний:
should neverплюс рівно один обов'язковий виняток — перевірка завантаження сторінки під час створення об'єкта. - Знаю, що Playwright припису не дає, але в офіційному прикладі імпортує
expectу файл page object і тримає локатори публічними — тобто демонструє протилежне кодом. - Знаю окрему позицію збоку: дока Cypress називає спільні 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, а не шукає по всій сторінці. - Впізнаю (усталений антипатерн ООП: багато незвʼязаних методів і типів; на практиці — клас на 1500 рядків і конфлікти в ) і лікую його розбиттям на компоненти за принципом єдиної відповідальності.
Той самий принцип поза UI
- Можу пояснити, що API-клієнт — той самий Page Object:
UsersApi.create()ховає URL і метод HTTP так само, якlogin()ховає селектор; корисний для швидкої підготовки стану через API. - Розумію критику POM (змішує локатори, дії, іноді перевірки) і що патерн Screenplay розводить ці ролі.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Яку проблему розв'язує Page Object?