Component Object ModelКомпоненти, вкладені об'єкти й повторне використання в тестах на Playwright
Ви вже вмієте будувати page objects для цілих сторінок. Тут ми розберемо, як розкласти сторінку на менші частини, кожна з яких має власний кореневий локатор, і як скласти з них сторінки, фікстури й тести без дублювання.
- @playwright/test 1.63
- TypeScript 7.0, strict
- Приклади перевірено
tsc --noEmitіplaywright test --list
Від POM до компонентів
Важливо одразу домовитися про статус теми. Component Object Model не є частиною API Playwright: у бібліотеці немає класу Component чи спеціального декоратора. Це спосіб організувати тестовий код, який спирається на звичайні, задокументовані механізми: локатори, їхні ланцюжки, фільтри та фікстури.
Зверніть увагу на слово «частину». Визначення не вимагає, щоб page object відповідав цілій сторінці. Саме тут і народжується компонентний підхід: якщо page object може описувати частину застосунку, то хедер, таблиця чи модальне вікно теж можуть мати власні об'єкти.
Проблема «товстих» page-класів
Уявіть адмін-панель із трьома розділами: користувачі, замовлення, товари. На кожній сторінці є однаковий хедер із меню облікового запису, однаковий пошук, таблиця і пагінація. Класичний POM, де кожна сторінка оголошує всі свої локатори самостійно, швидко перетворюється на копіювання одних і тих самих рядків у три класи.
Цей клас ще не великий, але подивіться на перші п'ять локаторів: вони не належать сторінці користувачів. Вони належать хедеру, пошуку й пагінації, які живуть на кожній сторінці адмінки. Коли дизайнер перейменує пункт меню «Log out», цей рядок доведеться виправити в усіх трьох класах, і легко пропустити один.
- Проблема
- Однакові фрагменти інтерфейсу описано в кожному page-класі окремо, тож локатори дублюються, а зміна в UI вимагає правок у кількох місцях.
- Чому
- Page-клас моделює сторінку, а повторюваний фрагмент є окремою сутністю, яка просто трапляється на багатьох сторінках.
- Рішення
- Описати фрагмент власним класом, який знає лише про свою частину DOM, а сторінку скласти з таких класів.
- Коли
- Коли фрагмент повторюється на двох і більше сторінках, або коли на одній сторінці є кілька однакових віджетів (рядки, картки), або коли page-клас став важко читати.
Розбери сторінку на компоненти
Натисніть на будь-яку зону макета (або оберіть її клавішею Tab і натисніть Enter). Праворуч з'явиться клас компонента, його кореневий локатор і місце в дереві вкладеності.
| Name | Actions |
|---|
Delete user
Анатомія компонента
Ключова ідея всього підходу вміщується в один рядок: компонент отримує в конструктор не Page, а Locator свого кореня. Корінь — це елемент, що обгортає весь фрагмент: банер хедера, діалог, таблицю, конкретний рядок. Усі внутрішні локатори компонент будує від кореня, а не від сторінки.
page (getByRole, getByLabel, getByTestId, locator тощо), так само доступні на класі Locator. Тому їх можна ланцюжити й поступово звужувати пошук до певної частини сторінки: root.getByRole('button', { name: 'Save' }) шукає кнопку лише всередині root.Друга властивість, на якій тримається патерн, — лінивість локаторів. Створення локатора не звертається до DOM. Елемент шукається в момент дії, і щоразу заново.
Саме тому створювати локатори в конструкторі безпечно: у момент new SearchBar(...) пошукового поля може ще не бути на сторінці, і це нормально. Воно знадобиться лише тоді, коли тест викличе search(), а Playwright дочекається його появи завдяки автоочікуванню перед дією.
Коли компоненту все ж потрібен Page
Деякі речі не належать жодному елементу: події завантаження файлів, діалоги браузера, навігація. Для них компоненту потрібен Page. Є два способи його отримати.
locator.page() повертає сторінку, якій належить локатор. Отже, компонент може отримати Page зі свого кореня без окремого параметра.this.root.page(): сигнатура конструктора лишається простою, і всі компоненти створюються однаково. Якщо компонент постійно працює зі сторінкою (слухає події, керує навігацією), передайте Page явно: залежність видно з конструктора, і її легше помітити під час рев'ю. Ціна явного параметра — у компонента з'являється «вихід» за межі кореня, тому стежте, щоб через нього не почали шукати звичайні елементи.- Проблема
- Компонент, що шукає елементи від
page, бачить усю сторінку і знаходить чужі кнопки та поля. - Чому
- Локатор від
pageне має меж, тож на сторінці з кількома однаковими віджетами він неоднозначний. - Рішення
- Передавати компоненту Locator його кореня й будувати всі внутрішні локатори через
root.getBy…(). - Коли
- Завжди, коли клас описує фрагмент сторінки. Page потрібен лише для того, що не має елемента: подій, діалогів, навігації.
BaseComponent
Коли компонентів кілька, у них з'являється спільна частина: кожен зберігає корінь. Її зручно винести в абстрактний базовий клас.
new, його можна лише успадкувати. Поле з модифікатором protected доступне в підкласах, але не ззовні, а readonly забороняє перевизначати його після конструктора.Що тут відбувається. Поле root захищене: підкласи будують від нього свої локатори, а тест не може ланцюжити від нього довільні пошуки і тим самим обходити API компонента. Водночас тесту інколи потрібно перевірити компонент цілком, наприклад що модалка зникла. Для цього є єдиний публічний геттер rootLocator, і більше в базовому класі нічого немає. Конструктор публічний: абстрактність класу вже не дає створити «просто компонент», а підкласи без власного конструктора залишаються створюваними.
Чому не варто тягнути в базовий клас «все про всяк випадок»
clickByText шукає від сторінки й руйнує скоуп, waitForSpinner знає про конкретну реалізацію інтерфейсу, а isDisplayed повертає миттєвий стан без очікування. Поле page у базовому класі змушує кожен компонент приймати Page, навіть коли він не потрібен. Додавайте в базовий клас лише те, що вже повторилося в кількох компонентах, а не те, що «може знадобитися».- Проблема
- Базовий клас обростає утилітами, і кожен компонент отримує методи, які йому не потрібні або порушують його межі.
- Чому
- Успадкування поширює все одразу на всіх нащадків, і прибрати метод згодом важче, ніж не додати його.
- Рішення
- Тримати в базі корінь і мінімальний доступ до нього, а поведінку описувати в конкретних компонентах.
- Коли
- Розширювати базу, коли той самий код з'явився щонайменше у двох-трьох компонентах і справді не залежить від їхнього змісту.
Прості компоненти
Почнімо з компонентів без вкладеності. Два правила роблять їх корисними. Перше: методи називаються мовою предметної області. Тест каже search('bob') чи goToPage(2), а не «клікни по кнопці з таким-то текстом». Метод-обгортка над одним click() без жодного сенсу нічого не додає до читабельності. Друге: внутрішні локатори обираються за пріоритетом із документації.
getByRole найближчий до того, як сторінку сприймають користувачі й допоміжні технології; getByLabel підходить для полів форм; getByTestId є найстійкішим до змін тексту чи ролі, але не орієнтований на користувача. CSS і XPath можуть ламатися при зміні структури DOM.Кореневий елемент пошуку зазвичай має роль search (елемент <search> або атрибут role="search"), тож сторінка створить компонент як new SearchBar(page.getByRole('search')). Поле input публічне, бо тесту корисно перевіряти його значення через toHaveValue; кнопка приватна, бо натискання вже інкапсульовано в search().
Модалка отримує корінь page.getByRole('dialog', { name: 'Delete user' }). Ім'я діалогу важливе: якщо на сторінці можуть з'явитися два діалоги, без імені корінь буде неоднозначним.
У пагінації exact: true не дає посиланню «2» збігтися з «12» чи «20», бо за замовчуванням ім'я ролі порівнюється як підрядок без урахування регістру. Поточну сторінку тут знайдено через атрибут aria-current CSS-селектором: серед опцій getByRole такого параметра немає, а атрибут є стабільним контрактом доступності, не деталлю верстки.
heading, currentPage, input), а ті, через які лише виконуються дії, ховайте за методами. Так API компонента показує, що з ним можна робити і що можна перевіряти.Конструктор компонента
Оберіть тип компонента, стратегію кореневого локатора і набір дій. Скелет класу оновлюється одразу.
Вкладені об'єкти
Компонент може містити інші компоненти. Хедер містить головне меню, меню містить випадаюче меню облікового запису. Таблиця містить рядки, рядок містить блок дій. Правило вкладення одне: дочірній компонент створюється від локатора всередині кореня батьківського. Тоді скоуп звужується на кожному рівні, і найглибший компонент шукає елементи у невеликому, однозначному фрагменті DOM.
Header → NavMenu → UserMenu
Ланцюжок коренів у підсумку такий: сторінка дає хедеру banner, хедер дає меню navigation з іменем «Main», а меню дає блоку облікового запису елемент з test id. Тест пише usersPage.header.nav.userMenu.logout(), і кожна ланка шукає лише всередині попередньої.
Table → Row → Actions
Таблиця — класичний приклад компонента-колекції. Рядків багато, вони однакові за структурою, тож рядок сам стає компонентом, а таблиця вміє повертати потрібний рядок.
filter({ hasText }) залишає елементи, які містять текст десь усередині, зокрема в нащадках; рядок порівнюється як підрядок без урахування регістру. filter({ has }) залишає елементи, що містять елемент за внутрішнім локатором. Внутрішній локатор має бути відносним: він шукається, починаючи від знайденого зовнішнього елемента, а не від кореня документа.Остання вимога пояснює, чому в rowWithCell внутрішній локатор побудовано від this.root.page(), а не від this.root. Локатор page.getByRole('cell', …) у ролі внутрішнього означає «клітинка всередині рядка». Ланцюжок від кореня таблиці означав би «таблиця, а в ній клітинка» всередині рядка, а таблиці в рядку немає, тож фільтр нічого б не знайшов. Цей самий прийом використано в прикладах документації: filter({ has: page.getByRole('heading', …) }).
first(), last() і nth() обирають елемент за позицією. Документація застерігає: сторінка може змінитися, і локатор вкаже на інший елемент, ніж очікувалося; краще знайти унікальний локатор. Тому rowAt() у таблиці доречний лише там, де порядок справді є предметом перевірки, наприклад після сортування.Масив компонентів через all()
locator.all() повертає масив локаторів для кожного знайденого елемента, але не чекає появи елементів і повертає те, що є на сторінці зараз. Якщо список змінюється динамічно, результат буде непередбачуваним; якщо список стабільний, але завантажується, треба дочекатися завершення завантаження. У розділі Lists документація відносить перебір елементів до рідкісних випадків, а для перевірки списку пропонує toHaveCount і toHaveText з масивом.table.rows) і web-first перевірками. Метод allRows() лишайте для випадків, коли з кожним рядком треба виконати дію, і завжди викликайте його після перевірки, що список уже стабільний.- Проблема
- У фрагменті є повторювані частини, і без вкладення батьківський компонент розростається до розмірів «товстої» сторінки.
- Чому
- Рядок, меню, картка мають власну поведінку і власні межі, які не залежать від того, де саме вони розміщені.
- Рішення
- Створювати дочірній компонент від локатора в межах батьківського кореня, а для колекцій повертати компонент-елемент через
filter. - Коли
- Коли в частини є кілька дій або вона повторюється. Якщо це одна кнопка, достатньо поля-локатора в батьківському компоненті.
Дерево вкладених компонентів
Розгортайте вузли і відкривайте код кожного компонента. Стрілки ліворуч і праворуч на клавіатурі згортають і розгортають обраний вузол.
Строгість і неоднозначність
count(), працюють без помилки. Від перевірки строгості можна явно відмовитися через first(), last() чи nth(), але документація цього не рекомендує.На сторінці з таблицею строгість проявляється одразу: у кожному рядку є кнопка «Delete», тож локатор від сторінки знаходить їх усі. Компонентний підхід розв'язує це не обхідним шляхом, а архітектурно: коли кнопку шукає рядок від свого кореня, у межах скоупу вона одна.
Те саме стосується кореня. Якщо page.getByRole('dialog') знаходить два діалоги, помилку кине перша ж дія всередині компонента, бо всі його локатори успадковують неоднозначний корінь. Тому корінь обирають так, щоб він однозначно ідентифікував фрагмент: за іменем ролі, test id чи фільтром.
Візуалізатор скоупу
Оберіть локатор і подивіться, скільки кнопок він знаходить у таблиці з чотирма рядками.
| Name | Actions |
|---|
Повторне використання
Тепер сторінки складаються з компонентів. Page-клас перестає описувати елементи: він лише вирішує, які компоненти є на сторінці і де їхні корені. Це композиція: сторінка має поля header, search, table, а не успадковує їх.
Хедер, пошук, таблиця і пагінація однакові в обох сторінках, різняться лише корінь таблиці та тип рядка. Логіка пошуку, меню і переходів між сторінками описана один раз, у компонентах.
Композиція проти успадкування
ConfirmDialog є модалкою з додатковим повідомленням, тому extends Modal природний: він отримує confirm() і close() і додає своє. Сторінка не є хедером, вона його має, тому хедер стає полем. Успадкування від компонента, щоб «отримати його методи», змішує дві сутності й закриває шлях до другого такого ж компонента на сторінці.Generic-таблиця з типізованим рядком
DataTable<TRow extends BaseComponent> з попереднього розділу — це одна реалізація таблиці для будь-якого типу рядка. Сторінка передає фабрику (row: Locator): UserRow => new UserRow(row), і TypeScript виводить TRow як UserRow. Тепер usersPage.table.rowByText(...) повертає саме UserRow з полем actions, а ordersPage.table.rowByText(...) повертає OrderRow з полем status, і компілятор не дасть переплутати.
TRow extends BaseComponent гарантує, що фабрика повертає компонент. Тип параметра класу виводиться з аргументів конструктора, тож явно писати new DataTable<UserRow>(…) не обов'язково, хоча в полі класу тип вказано явно: readonly table: DataTable<UserRow>.- Проблема
- Однаковий функціонал на різних сторінках потрібно описувати один раз, але кожна сторінка має свій набір фрагментів.
- Чому
- Успадкування дає лише одного предка, а сторінка складається з багатьох незалежних частин.
- Рішення
- Сторінка тримає компоненти як
readonlyполя і передає кожному його корінь. - Коли
- Завжди для зв'язку «сторінка має компонент».
extendsлишайте для «компонент є різновидом іншого компонента».
Товстий POM проти COM
Ті самі три сторінки до і після рефакторингу. Червоним позначено локатори, які повторюються в кількох класах.
Компоненти й фікстури
Фікстури детально розбираються в окремій темі, тож тут лише зв'язок із компонентами. Тесту не потрібно знати, як зібрано сторінку: він отримує готовий page object, у якому вже є всі компоненти.
test.extend з типом фікстур як параметром. Функція фікстури отримує інші фікстури першим аргументом і функцію use другим; значення передається в тест через await use(...), а код після цього виклику виконується як teardown.searchBar незабаром знадобиться для кожної сторінки окремо. Тест працює зі сторінкою і через неї дістається компонентів: usersPage.search, usersPage.table.Компоненти й assertions
Найчастіше питання про компоненти: де мають жити перевірки? Почнімо з того, як працюють самі перевірки в Playwright.
expect(locator).toBeVisible() чи toHaveText() повторюють перевірку, доки умова не виконається або не мине таймаут. Best practices прямо радять використовувати web-first assertions і не писати ручних перевірок на зразок expect(await locator.isVisible()).toBe(true): isVisible() повертає стан на поточну мить і не чекає.Тут дві проблеми. Якщо діалог з'являється з анімацією, isOpen() може повернути false за мить до появи, і тест мовчки пропустить підтвердження. А остання перевірка порівнює миттєве значення, тож після кліку діалог ще не встигає зникнути, і тест падає випадково. Обидві проблеми зникають, коли компонент надає локатори, а тест перевіряє їх web-first матчерами.
isOpen та isClosed. Якщо в кількох тестах повторюється та сама група перевірок, її можна винести в helper-функцію поруч із тестами, але й там вона має складатися з await expect(...), а не з if.Наскрізний тест
Так виглядає повний ланцюжок: BaseComponent, компоненти, сторінка, фікстура і тест.
- Проблема
- Методи-перевірки в компоненті з
isVisible()таifдають нестабільні тести, що проходять або падають залежно від таймінгу. - Чому
- Миттєві методи не чекають і не повторюють перевірку, на відміну від web-first матчерів.
- Рішення
- Компонент надає локатори, тест робить
await expect(component.locator)…з автоочікуванням. - Коли
- Для будь-якої перевірки стану UI. Розгалуження в тесті доречне лише тоді, коли сценарій справді має дві гілки, і документація показує, як дочекатися однієї з них через
or().
Особливі випадки
Компонент усередині iframe
locator.contentFrame() повертає FrameLocator, що вказує на той самий iframe, а методи створення локаторів доступні і на FrameLocator. Тобто від iframe можна побудувати звичайний Locator для елемента всередині. Водночас для filter({ has }) документація вимагає, щоб внутрішній і зовнішній локатори належали одному фрейму і внутрішній не містив FrameLocator.Компонент PaymentWidget нічого не знає про iframe: він отримує звичайний Locator і будує від нього свої локатори. Перехід у фрейм відбувається один раз, у сторінці. Сам iframe знайдено за атрибутом title через getByTitle, а не через CSS.
Власний атрибут для test id
getByTestId шукає атрибут data-testid. Інший атрибут задають опцією testIdAttribute у секції use конфігурації.Компоненти від цього не змінюються: root.getByTestId('row-actions') тепер шукатиме data-qa="row-actions". Це ще одна перевага того, що локатори зібрані в компонентах, а атрибут налаштований в одному місці.
Зрозумілі назви в trace viewer
locator.describe(description) задає опис, який показується в trace viewer і звітах, і повертає локатор на той самий елемент.Лише видимі елементи
locator.visible(), а в описі filter({ visible }) радить надавати перевагу саме цьому скороченню. Там само є примітка, що зазвичай краще знайти надійніший спосіб однозначно ідентифікувати елемент, ніж перевіряти видимість. Рольові локатори вже за замовчуванням не бачать елементів, прихованих з дерева доступності.Антипатерни й чек-лист
Кожен фрагмент нижче компілюється, але містить проєктну помилку. Спробуйте назвати її самостійно, а тоді відкрийте пояснення.
Чек-лист гарного компонента
| Перевірте | Чому це важливо |
|---|---|
Конструктор приймає Locator кореня, а не Page | Компонент обмежений своїм фрагментом і може існувати в кількох екземплярах на сторінці |
Усі внутрішні локатори будуються від root | Скоуп звужується, строгість не порушується через сусідні віджети |
| Корінь однозначний: роль з іменем, test id або фільтр | Неоднозначний корінь робить неоднозначними всі локатори компонента |
| Локатори обрано за пріоритетом best practices | getByRole, getByLabel, getByTestId стійкіші до змін верстки, ніж CSS-ланцюжки |
| Методи названо мовою предметної області | Тест читається як сценарій користувача, а не як послідовність кліків |
Немає методів-перевірок з isVisible() та if | Перевірки роблять web-first матчери в тесті з автоочікуванням |
| Компонент не імпортує сторінки | Його можна використати на будь-якій сторінці без циклічних залежностей |
| Вкладеність відповідає реальній структурі UI і лишається неглибокою | Тест не перетворюється на довгий ланцюжок проміжних об'єктів |
| Кожен локатор описано в одному компоненті | Зміна в UI виправляється в одному місці |
Поля-локатори readonly, async-методи мають явний Promise<…> | Контракт компонента видно з типів, і його не змінять випадково |
Фінальний квіз
Десять запитань на перевірку розуміння. Після відповіді з'являється пояснення з посиланням на документацію.