Advanced POM Patterns

Тема 07 курсу «Playwright + TypeScript»

Advanced POM Patterns: Fluent API, BasePage, Lazy Locators, Inheritance

const catalog = await new CatalogPage(page).open(); await catalog.addToCart('Desk lamp'); const cart = await catalog.goToCart(); // тип: CartPage — IDE знає, де ми await expect(cart.itemRow('Desk lamp')).toBeVisible();
Час опрацювання: ≈ 2,5–3 години Рівень: середній Версії: Playwright 1.63, TypeScript 7.0 (вересень 2026) Домен прикладів: абстрактний інтернет-магазин

Після цієї теми ви зможете…

  • пояснити, чому локатори Playwright «ліниві», і безпечно оголошувати їх у конструкторі page object-а;
  • спроєктувати абстрактний BasePage зі спільним контрактом (open(), expectLoaded()) і не перетворити його на смітник;
  • використовувати механіку наслідування TypeScript (abstract, protected, override, поліморфний this) і вирішувати, де краща композиція;
  • будувати Fluent API з урахуванням асинхронності: методи, що повертають Promise<this> або об'єкт наступної сторінки;
  • діагностувати типові помилки ієрархій page object-ів через trace viewer, UI mode і --debug.

Передумови та зв'язок з курсом

Нам знадобляться три речі з попередніх тем: розуміння автоочікування (auto-waiting) і перевірок actionability з теми про локатори, базовий Page Object Model з теми 4 і Component Object Model з теми 5 — зокрема ідея, що компонент отримує кореневий Locator і шукає всередині нього. Сьогодні ми не вводимо новий інструмент, а «дотягуємо» архітектуру: спільний базовий клас, правила наслідування і зручний для читання API page object-ів. У кінці ви побачите, куди ця архітектура веде далі — до фікстур (fixtures).

Навіщо це все: три болі зрілого POM

Уявімо проєкт через пів року після старту: 60 тестів, 14 page object-ів, три людини в команді. Базовий POM з теми 4 вже є, але тести все одно болять.

Перший біль — дублювання. Кожна сторінка магазину має однаковий хедер із пошуком і кошиком, тож кожен page object має власну копію локатора кнопки кошика і свій варіант методу goto(). Дизайнери перенесли кошик у бокове меню, і вам треба виправити одне й те саме в дев'яти файлах. Пропустили один — і три тести падають так, ніби зламався застосунок, а не автотести.

Другий біль — flaky-тести. Хтось із команди «оптимізував» page object: знайшов елементи один раз у конструкторі й зберіг їх як ElementHandle. Поки React не перемальовує список товарів, усе працює; після чергового ререндеру тест падає з повідомленням, що елемент більше не прикріплений до DOM. Падає раз на десять запусків — найгірший сценарій для довіри до тестів.

Третій біль — читабельність. Тест виглядає як await (await (await catalog.search('lamp')).addToCart('lamp')).goToCart(), або, навпаки, як «простирадло» з двадцяти рядків, де тест сам створює new CartPage(page), бо мусить знати, на яку сторінку потрапив після кліку. Ніхто не хоче такий тест чіпати.

Чотири патерни цієї теми відповідають на ці болі: лінивий локатор (lazy locator) прибирає клас помилок «застарілого елемента», базова сторінка (BasePage) і наслідування (inheritance) прибирають дублювання, а Fluent API робить тести читабельними і змушує типи описувати навігацію застосунком.

Лінивий локатор (lazy locator)

Проблема

Елемент «знайдено» в одному стані сторінки, а дію виконують в іншому. Між цими моментами фреймворк перемалював DOM, і збережене посилання вказує на вузол, якого вже немає.

Чому так

Сучасні SPA постійно замінюють DOM-вузли: оновлення стану, пагінація, повторний запит до API. Посилання на конкретний вузол (так працює ElementHandle) не «переїжджає» разом з елементом — воно залишається прив'язаним до старого, від'єднаного вузла.

Рішення

Locator зберігає не вузол, а опис того, як його знайти. Пошук відбувається заново під час кожної дії чи web-first перевірки, разом з автоочікуванням і перевіркою строгості (strictness).

Коли

Завжди для взаємодії та перевірок. Знімок стану (count(), all(), textContent()) — лише коли вам справді потрібне значення «тут і зараз», і лише після web-first перевірки.

Механіка: що відбувається в момент створення локатора

Виклик page.getByRole('button', { name: 'Add to cart' }) синхронний і не звертається до браузера взагалі. Він повертає об'єкт, який лише запам'ятовує ланцюжок: «на цій сторінці знайти кнопку з таким доступним ім'ям». Тому його не треба await-ити, і тому його можна створити навіть тоді, коли сторінка ще не відкрита. Справжній пошук стається, коли ви викликаєте асинхронний метод: click(), fill() або expect(locator).toBeVisible(). У цей момент Playwright резолвить локатор, чекає, доки елемент стане придатним до дії, і — якщо DOM змінився між спробами — просто шукає знову.

Саме ця лінивість дозволяє оголошувати локатори полями page object-а в конструкторі: конструктор синхронний, сторінка може бути ще порожньою, а локатори все одно коректні, бо вони лише описи.

pages/CatalogPage.ts — фрагмент

Три способи оголосити локатор у page object-і

Поле, ініціалізоване в конструкторі, — стандарт для стабільних елементів сторінки: заголовок, поле пошуку, список карток. Саме так побудовано приклад в офіційному гайді Page object models. Фабричний метод на кшталт productCard(name) потрібен, коли локатор залежить від параметра: ви не можете заздалегідь створити поле для кожного товару, але можете повертати звужений локатор на вимогу. Геттер (get firstCard()) технічно працює так само, як метод без аргументів, і створює новий Locator при кожному зверненні; це дешево, але зазвичай не дає переваг над полем, тож використовуйте геттери лише тоді, коли локатор обчислюється з інших полів і ви хочете підкреслити цей зв'язок.

⚠️ Увага: не всі методи Locator ліниві

Методи на кшталт locator.all(), count(), textContent() повертають знімок стану в конкретний момент. Документація прямо попереджає: all() не чекає на появу елементів і повертає те, що є на сторінці зараз. Якщо список ще завантажується, ви отримаєте порожній масив, і цикл for тихо нічого не перевірить. Спочатку зафіксуйте стан web-first перевіркою (await expect(cards).toHaveCount(3)), а вже потім ітеруйте.

📌 Нове у версіях 1.51–1.63

З версії 1.51 locator.filter({ visible: true }) відфільтровує лише видимі елементи. З версії 1.53 locator.describe('…') дає локатору зрозумілу назву, яку видно в trace viewer і звітах, — корисно для полів page object-а. З версії 1.63 з'явився locator.visible() — рекомендована заміна CSS-псевдокласу :visible.

locator.describe() для читабельного трейсу (з 1.53)

Базова сторінка (BasePage)

Проблема

Кожен page object заново реалізує відкриття сторінки, перевірку «сторінка завантажилась» і доступ до спільних компонентів — хедера, футера, сповіщень.

Чому так

Page object-и створювались незалежно, і між ними немає спільного контракту. Кожен автор вирішив по-своєму, як називати метод відкриття і що вважати «завантаженою» сторінкою.

Рішення

Абстрактний клас BasePage тримає спільний стан (page, хедер) і спільну поведінку (open()), а специфічні деталі (шлях, ознака завантаження) лишає нащадкам через абстрактні члени.

Коли

Коли у вас є хоча б кілька сторінок з однаковою структурою відкриття і спільною «рамкою» інтерфейсу. Не коли хочеться мати одне місце для всіх утиліт проєкту.

Контракт замість набору утиліт

Найкорисніше, що дає BasePage, — не економія рядків, а контракт: кожна сторінка гарантовано вміє відкриватися за своєю адресою і гарантовано знає, як переконатися, що вона завантажилась. Метод open() описує алгоритм один раз — перейти за шляхом і дочекатися ознаки завантаження, — а конкретні кроки алгоритму (який шлях, який елемент чекати) заповнюють нащадки. Це класичний патерн «шаблонний метод» (Template Method): загальна послідовність у базовому класі, змінні кроки — у підкласах.

pages/BasePage.ts

Зверніть увагу на три рішення в цьому коді. Поле page має модифікатор protected: нащадкам воно потрібне, а тестам — ні, бо тест, який лізе в catalog.page повз методи page object-а, руйнує інкапсуляцію. Метод expectLoaded() абстрактний, тож TypeScript не дасть створити сторінку, яка «забула» визначити ознаку завантаження. А page.goto(this.path) з відносним шляхом працює, бо baseURL ви налаштували в playwright.config.ts ще в темі про Test Runner.

📌 З версії 1.39: test.step з опцією box

Якщо expectLoaded() впаде всередині open(), без box: true стек помилки вкаже в надра BasePage.ts. З box: true помилка вказує на рядок тесту, де викликано open(), — саме там, де людині зручно почати розбиратися. test.step також повертає значення, яке повернув колбек, тож крок можна загорнути навколо методу, що щось обчислює.

Що належить у BasePage, а що ні

У базовий клас варто класти те, що справді спільне для кожної сторінки: посилання на page, компоненти глобальної «рамки» (хедер, тост-сповіщення), алгоритм відкриття і контракт завантаження. Не належать туди логін (це стан сесії — його розв'язують фікстури і storageState у наступних темах), виклики API, генерація тестових даних, «універсальні» методи на кшталт clickByText() і будь-які явні паузи. Правило перевірки просте: якщо метод має сенс лише для половини сторінок, він не належить базовому класу.

💡 Порада

Тримайте expectLoaded() на одному-двох найбільш характерних елементах сторінки — заголовку h1 або унікальному контейнері. Перевіряти в ньому весь вміст сторінки не треба: це ознака завантаження, а не тест-кейс.

Наслідування (inheritance) в ієрархії page object-ів

Проблема

Спільний код потрібно не лише винести, а й зробити так, щоб нащадки не могли зламати контракт: забути ознаку завантаження, випадково перевизначити не той метод, втратити тип у ланцюжку викликів.

Чому так

JavaScript-класи самі по собі нічого не гарантують: перевизначення методу з помилкою в назві просто створить новий метод, а базовий тихо продовжить працювати.

Рішення

Модифікатори TypeScript: abstract змушує реалізувати, protected ховає від тестів, override разом з noImplicitOverride ловить помилки перевизначення, а поліморфний тип this зберігає точний тип нащадка.

Коли

Для відношення «є» (is-a): CatalogPage є сторінкою. Для відношення «має» (has-a) — хедер, фільтр, модальне вікно — використовуйте композицію компонентів.

Порядок ініціалізації: чому super(page) має бути першим

У похідному класі конструктор зобов'язаний викликати super(page) до будь-якого звернення до this — TypeScript видасть помилку компіляції, а JavaScript кине ReferenceError під час виконання. Механіка така: об'єкт створює базовий клас, і лише після повернення з super() похідний клас отримує готовий this, у якому вже є this.page і this.header. Звідси практичний наслідок: у нащадку можна будувати локатори на основі this.header або page, бо все базове вже ініціалізовано.

pages/CatalogPage.ts

override і noImplicitOverride

Ключове слово override (TypeScript 4.3+) повідомляє компілятору: «я свідомо перевизначаю член базового класу». Якщо в базовому класі такого члена немає — наприклад, ви написали expectLoded з помилкою, — компілятор повідомить про це. Опція noImplicitOverride у tsconfig.json робить правило обов'язковим в інший бік: перевизначення без override стає помилкою. Разом вони закривають цілий клас тихих багів, коли нащадок «перевизначив» метод, якого не існує, і базова логіка продовжила працювати непомітно.

⚠️ Playwright не перевіряє типи під час запуску

Playwright транспілює TypeScript-тести і запускає їх навіть за наявності некритичних помилок типів — так описано в розділі документації про TypeScript. Щоб override, abstract і пропущені await справді вас захищали, запускайте перевірку типів окремо (наприклад, npx tsc --noEmit у CI або в pre-commit хуку).

Поліморфний this: тип, що «підлаштовується» під нащадка

Метод open() оголошено в BasePage, але повертає він Promise<this>, а не Promise<BasePage>. Тип this у TypeScript означає «тип того об'єкта, на якому викликали метод». Тому await new CatalogPage(page).open() має тип CatalogPage, і IDE одразу підказує search() та addToCart(). Якби повертався BasePage, тест мусив би робити приведення типу (as CatalogPage), а це мовчазна брехня компілятору.

Композиція проти наслідування

Наслідування жорстко зв'язує класи: зміна базового класу зачіпає всіх нащадків, а TypeScript дозволяє лише одного батька. Тому ієрархію page object-ів тримають пласкою — зазвичай BasePage і один рівень конкретних сторінок, іноді проміжний клас для групи сторінок зі спільним макетом (наприклад, особистий кабінет з боковим меню). Усе, що з'являється лише на частині сторінок, — фільтр товарів, модальне вікно підтвердження, пагінація — оформлюйте компонентами з теми 5 і підключайте полями. Тест на вибір простий: якщо можна сказати «сторінка каталогу має фільтр», це композиція; якщо «сторінка каталогу є сторінкою магазину», це наслідування.

✅ Краща практика

Дві-три сходинки ієрархії — максимум. Якщо вам хочеться створити ProductPage → ElectronicsProductPage → LaptopProductPage, найімовірніше, відмінності між ними — це дані, а не поведінка, і їх краще передати параметром.

Fluent API в асинхронному світі

Проблема

Тест мусить сам знати, на якій сторінці він опинився після кліку, і сам створювати new CartPage(page). Знання про навігацію застосунком розмазане по тестах.

Чому так

Методи page object-ів нічого не повертають (Promise<void>), тож типи не несуть інформації, куди веде дія. Класичний Fluent API з Selenium Java (page.search().addToCart()) переносять «в лоб», але в Playwright кожна дія асинхронна.

Рішення

Метод, що лишає користувача на тій самій сторінці, повертає Promise<this>; метод, що переводить на іншу сторінку, повертає Promise<НаступнаСторінка>. Кожен крок — окремий await. Для синхронних об'єктів даних — повноцінний Fluent Builder.

Коли

Коли в застосунку є зрозумілі переходи між сторінками. Не варто повертати this з методів, що повертають дані, і не варто заради «ланцюжка» жертвувати одним await на рядок.

Чому «класичний» ланцюжок не працює

Асинхронний метод завжди повертає Promise, навіть якщо всередині написано return this. Тому catalog.search('lamp').addToCart('lamp') не скомпілюється: у Promise<CatalogPage> немає методу addToCart. Щоб отримати сам об'єкт, проміс треба дочекатися, і ланцюжок перетворюється на вкладені дужки або на .then():

❌ Технічно працює, читати неможливо

Тому в Playwright цінність Fluent API не в синтаксисі ланцюжка, а в типах, які описують карту переходів. Коли goToCart() повертає Promise<CartPage>, тест більше не вирішує, який page object створити, — він просто отримує правильний, і IDE показує лише ті дії, які доступні на поточній сторінці.

✅ Один await на крок, типи ведуть тест

Метод переходу: хто відповідає за «прибуття»

Метод, що повертає нову сторінку, бере на себе відповідальність переконатися, що перехід справді відбувся: клікнути, створити об'єкт наступної сторінки і дочекатися його expectLoaded(). Якщо цього не зробити, тест отримає об'єкт CartPage, коли браузер ще показує каталог, і перша ж дія впаде з незрозумілою помилкою про відсутній елемент.

pages/CatalogPage.ts — методи з Fluent API

Fluent Builder: де ланцюжок справді доречний

Там, де немає асинхронності, класичний Fluent API працює повною мірою. Типовий приклад — будівник (Builder) об'єкта даних: набір фільтрів каталогу збирається синхронно, кожен метод повертає this, а фінальний build() віддає незмінний типізований об'єкт. Page object отримує вже готові дані одним параметром.

test-data/ProductFilterBuilder.ts
💡 Де межа між темами

Builder тут — лише спосіб зручно зібрати аргумент для методу page object-а. Як генерувати набори таких даних і запускати один тест на багатьох наборах, ми розберемо в темі про параметризацію та data-driven testing.

Практичні приклади: збираємо міні-фреймворк

Зберемо всі чотири патерни в одну узгоджену структуру. Приклади йдуть від фундаменту (компонент і база) до тесту, який лише викликає методи й перевіряє результат.

tests/ cart.spec.ts pages/ BasePage.ts CatalogPage.ts CartPage.ts components/ HeaderComponent.ts test-data/ ProductFilterBuilder.ts playwright.config.ts // baseURL, trace: 'on-first-retry'

Приклад 1. Компонент хедера

Демонструє композицію: хедер не є сторінкою, тому він не наслідує BasePage, а отримує кореневий локатор і будує свої локатори відносно нього — так само, як у темі 5.

components/HeaderComponent.ts

Приклад 2. Сторінка кошика з переходом назад

Демонструє фабричний метод локатора, метод, що лишає користувача на сторінці (Promise<this>), і метод переходу. Імпорт CatalogPage тут безпечний, хоча CatalogPage теж імпортує CartPage: обидва класи звертаються один до одного лише всередині методів, тобто вже після того, як модулі повністю завантажились.

pages/CartPage.ts

Приклад 3. Кроки з параметрами для звіту

Демонструє, як зробити дії page object-а читабельними у trace viewer і HTML-звіті. Кожен бізнес-крок загорнуто в test.step; опція box переносить помилку на рядок тесту, а subtitle і params відображаються поруч із назвою кроку.

📌 З версії 1.63: subtitle і params у test.step

Опції subtitle і params з'явилися в Playwright 1.63; на старіших версіях залиште лише box: true.

pages/CatalogPage.ts — addToCart з кроком

Приклад 4. Метод, що приймає об'єкт від Fluent Builder

Демонструє зв'язку синхронного будівника й асинхронного page object-а. Метод розбирає типізований об'єкт і виконує лише ті дії, які в ньому задано.

pages/CatalogPage.ts — applyFilter

Приклад 5. Тест, який лише викликає й перевіряє

Демонструє результат: тест не знає про локатори, не створює сторінки після переходів і не містить службової логіки. Перевірки — web-first і стоять у тесті, де їх видно.

tests/cart.spec.ts

Куди далі. Помітили повторюваний рядок await new CatalogPage(page).open() на початку кожного тесту? Саме це розв'язують фікстури: page object створюється і, за потреби, відкривається до початку тесту, а тест просто отримує готовий catalog в аргументах. Як оголошувати власні фікстури, як працюють області видимості (test і worker) і як вони поєднуються з POM — тема наступного заняття «Fixtures. Advanced».

Антипатерни

1. Елементи, знайдені «наперед» через ElementHandle

❌ Погано

Чому це погано. ElementHandle прив'язаний до конкретного DOM-вузла на момент init(). Після будь-якого ререндеру списку (фільтр, пагінація, прихід даних з API) вузол від'єднується, і клік падає або, що гірше, влучає в застарілий елемент, якого користувач уже не бачить. Результат — flaky-тести, що падають залежно від швидкості мережі. Документація Playwright прямо не рекомендує ElementHandle і радить локатори з web-first перевірками. Крім того, CSS-селектор за класами прив'язаний до верстки, а не до того, що бачить користувач.

✅ Краще

2. «Всемогутній» BasePage

❌ Погано

Чому це погано. Кожна сторінка тепер «вміє» логінитись і створювати товари через API, хоча до її поведінки це не має стосунку. Базовий клас змінюється з будь-якої причини, тож кожна зміна зачіпає всі сторінки й усі тести. clickByText ховає справжній локатор від читача тесту, а waitForTimeout(3000) робить кожне відкриття на три секунди повільнішим і водночас не гарантує готовність: на повільному CI трьох секунд не вистачить, на швидкому — це марнування часу. Документація називає waitForTimeout інструментом лише для налагодження.

✅ Краще

3. Метод переходу, що «бреше» типом

❌ Погано

Чому це погано. Типи обіцяють, що після виклику доступні методи каталогу. Компілятор мовчить, IDE підказує search(), а під час виконання пошуку на сторінці кошика немає — тест чекає до таймауту і падає з повідомленням про відсутній елемент, яке нічого не говорить про справжню причину. Fluent API, що не відповідає реальній навігації, гірший за його відсутність.

✅ Краще

4. Глибока ієрархія заради дрібних відмінностей

❌ Погано

Чому це погано. Щоб зрозуміти, звідки в SalePage метод nextPage(), треба пройти п'ять файлів. Коли на сторінці розпродажу приберуть фільтр, доведеться перебудовувати всю гілку ієрархії. Пагінація і фільтр — це не «вид сторінки», а блоки, які сторінка має; їх природна форма — компоненти.

✅ Краще

5. Перевірки, сховані в методах дій

❌ Погано

Чому це погано. Метод працює лише в сценарії «порожній кошик плюс один товар». Тест, що додає другий товар, падає не тому, що застосунок зламався, а тому, що дія містить вшите очікування. Крім того, читач тесту не бачить, що саме перевіряється, — отримуємо приховані перевірки і хибнопадаючі тести. Методи дій виконують дію; очікуваний результат формулює тест.

✅ Краще

Типові помилки та дебаг

TypeError: Cannot read properties of undefined (reading 'getByRole')
Симптом
Тест падає одразу при створенні page object-а, ще до першої дії в браузері.
Причина
Локатор оголошено ініціалізатором поля (readonly payButton = this.page.getByRole(...)) у класі, який не наслідує іншого, а this.page присвоюється в тілі конструктора. За правилами JavaScript ініціалізатори полів такого класу виконуються до тіла конструктора, тож this.page ще undefined. У похідному класі ініціалізатори виконуються одразу після super(), тому там та сама конструкція працює — звідси «незрозуміло, чому в одному класі падає, а в іншому ні».
Як виправити
Присвоюйте локатори в конструкторі після this.page = page (або після super(page)). Єдиний стиль для всіх page object-ів знімає питання порядку ініціалізації.
TypeError: Class extends value undefined is not a constructor or null
Симптом
Падає завантаження тестового файлу, жоден тест не стартує.
Причина
Циклічний імпорт, у якому бере участь extends. Наприклад, BasePage імпортує CartPage (щоб мати спільний метод goToCart()), а CartPage наслідує BasePage. Коли модуль CartPage виконує class CartPage extends BasePage, модуль BasePage ще не завершив ініціалізацію, і на місці батьківського класу — undefined. Взаємні імпорти між «сестрами» (CatalogPage ↔ CartPage) безпечні, доки класи звертаються один до одного лише всередині методів.
Як виправити
Базовий клас ніколи не повинен імпортувати своїх нащадків. Методи переходу розміщуйте в конкретних сторінках або в компоненті (наприклад, у хедері повертайте локатор, а перехід робіть у сторінці).
Error: strict mode violation: getByRole('link', { name: 'Cart' }) resolved to 2 elements
Симптом
Локатор з BasePage працював на всіх сторінках, а на новій сторінці падає.
Причина
Локатор побудовано від page, а не від кореня компонента, і на новій сторінці з'явився другий елемент з тим самим доступним ім'ям (наприклад, посилання «Cart» у футері). Строгість локатора — не помилка Playwright, а попередження, що ви не знаєте, по якому елементу клікнете.
Як продебажити
Запустіть npx playwright test cart.spec.ts --ui, відкрийте крок і скористайтеся інструментом вибору локатора, щоб побачити обидва збіги. Виправлення — будувати локатори компонента від його root (page.getByRole('banner')), а не від усієї сторінки.
Property 'goToCart' does not exist on type 'Promise<CatalogPage>'. Did you forget to use 'await'?
Симптом
Червоне підкреслення в IDE або помилка tsc у рядку з ланцюжком.
Причина
Спроба викликати метод на результаті асинхронного методу без await. Небезпечніший «родич» цієї помилки — catalog.addToCart('x'); без await взагалі: TypeScript не скаржиться, дія виконується паралельно з наступними рядками, і тест стає нестабільним.
Як виправити
Один await на крок. Для промісів без await увімкніть правило ESLint @typescript-eslint/no-floating-promises — його радить розділ Best Practices документації Playwright.
Error: Timed out 5000ms waiting for expect(locator).toBeVisible() Locator: getByRole('heading', { name: 'Your cart', level: 1 })
Симптом
Падає метод переходу (goToCart()), хоча клік пройшов успішно.
Причина
Або перехід справді не відбувся (клік потрапив не туди, з'явилась модалка), або expectLoaded() цільової сторінки чекає на елемент, якого там немає (змінився текст заголовка). Без box: true стек вказує всередину page object-а, і незрозуміло, з якого тесту все почалося.
Як продебажити
Увімкніть trace: 'on-first-retry' і відкрийте трейс (npx playwright show-trace або з HTML-звіту): у вкладці знімків DOM видно, на якій сторінці був браузер у момент перевірки. Для покрокового проходження запустіть тест з --debug і зупиніться перед викликом goToCart().

Спробуй сам

Завдання 1. CheckoutPage у спільній ієрархії

У магазині є сторінка оформлення замовлення за адресою /checkout із заголовком «Checkout» (рівень 1), полями з підписами «Full name» і «Address», кнопкою «Place order» і повідомленням з data-testid="order-confirmation", що з'являється після успішного замовлення. Створіть CheckoutPage, що наслідує BasePage, додайте в CartPage метод переходу proceedToCheckout() (кнопка «Checkout» у кошику) і напишіть тест «користувач оформлює замовлення з одним товаром». Тест має лише викликати методи й перевіряти результат.

Підказка

Метод заповнення форми лишає користувача на тій самій сторінці — отже, він повертає Promise<this>. Підтвердження замовлення показується на тій самій сторінці, тож окремий page object для нього не потрібен: достатньо публічного локатора, який тест перевірить через toBeVisible(). Не забудьте override для path і expectLoaded(), а в proceedToCheckout() — дочекатися завантаження нової сторінки.

Розв'язок
pages/CheckoutPage.ts
pages/CartPage.ts — новий метод
tests/checkout.spec.ts

Зверніть увагу: тест жодного разу не створює сторінку після переходу, а типи змінних cart і checkout виводяться автоматично з методів переходу.

Завдання 2. Знайдіть і виправте три проблеми

Колега надіслав на рев'ю наведений нижче page object. Знайдіть три проблеми, поясніть механізм кожної (до чого конкретно вона призведе) і перепишіть клас.

pages/WishlistPage.ts — на рев'ю
Підказка

Подивіться уважно на назву методу завантаження (і на те, що скаже компілятор, якщо ввімкнено noImplicitOverride), на момент, коли викликається all(), і на тип, який повертає останній метод після кліку по кошику.

Розв'язок

Проблема 1 — опечатка в expectLodaed. Абстрактний expectLoaded() не реалізовано, тож клас не скомпілюється (не можна наслідувати абстрактний клас, не реалізувавши абстрактні члени). Якби метод у базі був не абстрактним, опечатка тихо створила б новий метод, і open() викликав би базову реалізацію. Ключове слово override з noImplicitOverride ловить такі випадки одразу.

Проблема 2 — all() без попередньої перевірки. Якщо список ще завантажується, all() поверне порожній масив і метод «успішно» нічого не зробить. Навіть якщо елементи є, після кожного переміщення список ререндериться, а збережені в масиві локатори nth(i) зсуваються. Надійніше щоразу брати перший елемент, доки список не спорожніє, і перевіряти кількість web-first перевіркою.

Проблема 3 — метод «бреше» типом. Після кліку по кошику браузер на /cart, а метод повертає WishlistPage. Також CSS-селектор .wishlist li прив'язаний до верстки — краще роль або test id.

pages/WishlistPage.ts — виправлено

Ми також розділили «перемістити все» і «перейти в кошик» на два методи: кожен робить одну дію, а тест вирішує, чи потрібен перехід.

Квіз для самоперевірки

11 питань різних типів. Після відповіді з'являються пояснення до кожного варіанта. Питання з кількома правильними відповідями позначені окремо — оберіть усі потрібні й натисніть «Перевірити».

Відповідей: 0 з 11

Шпаргалка

Lazy locators

  • page.getByRole(...) — синхронний, без звернення до DOM
  • Резолвиться при кожній дії та expect
  • Поля — для стабільних елементів, фабричні методи — для параметризованих
  • all(), count() — знімки; спершу web-first перевірка
  • describe() (1.53), filter({ visible: true }) (1.51), visible() (1.63)

BasePage

  • abstract class + protected readonly page
  • protected abstract readonly path
  • abstract expectLoaded(): Promise<void>
  • open(): Promise<this> — goto + expectLoaded
  • Не класти: логін, API, дані, waitForTimeout

Наслідування

  • super(page) — першим у конструкторі
  • override + noImplicitOverride у tsconfig
  • is-a → extends, has-a → компонент-поле
  • Ієрархія ≤ 2–3 рівнів
  • База не імпортує нащадків (циклічні імпорти)

Fluent API

  • Лишаємось на сторінці → Promise<this>
  • Перехід → Promise<NextPage> + expectLoaded()
  • Один await на крок, без вкладених дужок
  • Синхронний Builder: методи → this, build() → дані

test.step у page object-і

  • test.step(title, body, { box: true }) (1.39)
  • subtitle, params (1.63)
  • Повертає значення колбека

Дебаг

  • npx playwright test --ui
  • npx playwright test file.spec.ts --debug
  • trace: 'on-first-retry' → npx playwright show-trace
  • npx tsc --noEmit — Playwright типи не перевіряє
  • ESLint: @typescript-eslint/no-floating-promises

Питання для співбесіди (Middle QA Automation)

Чому в Playwright можна створювати локатори в конструкторі page object-а, а в класичному Selenium PageFactory з цим були проблеми?

Локатор Playwright — це лінивий опис пошуку, а не посилання на знайдений елемент. Його створення синхронне й не звертається до браузера; пошук відбувається під час кожної дії чи web-first перевірки, разом з автоочікуванням і повторними спробами. Тому стан DOM у момент створення page object-а не має значення. Проблеми виникають, коли замість локатора зберігають конкретний вузол — ElementHandle у Playwright чи знайдений WebElement у Selenium: після ререндеру такий вузол від'єднаний від DOM. Сильна відповідь також згадає, що не всі методи локатора ліниві: all() і count() дають знімок стану.

Як реалізувати Fluent API, якщо всі дії в Playwright асинхронні? Які компроміси?

Асинхронний метод повертає Promise, тож класичний ланцюжок без await неможливий. Практичний підхід: методи, що лишають користувача на сторінці, повертають Promise<this>, а методи переходу — Promise<NextPage>, попередньо дочекавшись завантаження нової сторінки. У тесті — один await на крок. Цінність не в синтаксисі ланцюжка, а в тому, що типи описують навігацію: тест не створює сторінки вручну, IDE показує лише доречні методи, а зміна навігації в застосунку вимагає змін в одному методі переходу. Компроміси: метод переходу бере на себе перевірку «прибуття», а для умовних переходів (наприклад, успіх або помилка) потрібні окремі методи або явне розгалуження. Ланцюжок через .then() чи вкладені await технічно можливий, але погіршує читабельність і стек помилок.

Що ви покладете в BasePage і чого там точно не буде?

У BasePage — лише те, що справді спільне для кожної сторінки: посилання на page (protected), компоненти глобальної рамки інтерфейсу, алгоритм відкриття (open() як Template Method) і абстрактний контракт завантаження (expectLoaded()). Не буде логіну (це стан сесії — фікстури і storageState), API-викликів, генерації даних, «універсальних» обгорток над кліками і явних пауз. Аргумент: базовий клас змінюється разом з усіма нащадками, тому кожен зайвий метод множить ризик регресій і робить ієрархію важкою для розуміння.

Коли ви оберете композицію замість наслідування для page object-ів?

Наслідування — для відношення «є»: сторінка каталогу є сторінкою магазину і повинна мати спільний контракт відкриття. Композиція — для відношення «має»: фільтр, пагінація, модальне вікно, хедер. Композиція дає гнучкість (одна сторінка має фільтр, інша ні), уникає глибоких ієрархій, які важко читати, і дозволяє перевикористовувати блоки між сторінками з різними базовими класами. Практичне правило — не більше двох-трьох рівнів наслідування; усе, що з'являється на частині сторінок, стає компонентом-полем.

Тест із page object-ами падає з «strict mode violation». Як ви підійдете до діагностики?

Повідомлення означає, що локатор знайшов кілька елементів для дії, яка вимагає одного. Спершу — зрозуміти, які саме елементи збіглися: UI mode або trace viewer показують стан DOM, а інструмент вибору локатора підсвічує збіги. Типова причина в POM — локатор компонента побудовано від page, а не від кореня компонента, і на новій сторінці з'явився дубль (наприклад, та сама кнопка в хедері й футері). Виправлення — звузити область пошуку до root компонента або уточнити локатор через filter(). Не варто «лікувати» це через .first() без розуміння, бо тест почне клікати по першому-ліпшому елементу.

Глосарій

Об'єкт сторінки Page Object
Клас, що інкапсулює локатори й дії однієї сторінки застосунку.
Базова сторінка BasePage
Абстрактний батьківський клас зі спільним станом і контрактом для всіх page object-ів.
Лінивий локатор lazy locator
Локатор, що зберігає опис пошуку і резолвиться заново під час кожної дії чи перевірки.
Дескриптор елемента ElementHandle
Посилання на конкретний DOM-вузол; не рекомендований на користь локаторів.
Строгість strictness
Правило, за яким дія над локатором падає, якщо він знаходить більше одного елемента.
Наслідування inheritance
Механізм extends, коли клас отримує стан і поведінку батьківського класу.
Композиція composition
Побудова об'єкта з інших об'єктів-полів (компонентів) замість наслідування.
Абстрактний член abstract member
Властивість чи метод без реалізації, яку зобов'язаний надати нащадок.
Перевизначення override
Заміна реалізації члена базового класу в нащадку; в TS позначається ключовим словом override.
Поліморфний this polymorphic this type
Тип this, що означає тип конкретного об'єкта, на якому викликано метод.
Шаблонний метод Template Method
Патерн: алгоритм у базовому класі, змінні кроки — в нащадках.
Плавний інтерфейс Fluent API / fluent interface
Стиль API, де методи повертають об'єкт для наступного виклику, утворюючи читабельну послідовність.
Будівник Builder
Патерн покрокового збирання об'єкта з фінальним методом build().
Web-first перевірка web-first assertion
Перевірка expect(locator), що автоматично чекає, доки умова виконається.
Циклічний імпорт circular import
Ситуація, коли модулі імпортують один одного; небезпечна разом з extends.
Фікстура fixture
Механізм Playwright Test для підготовки та передачі об'єктів у тест; тема наступного заняття.