Локатори: стратегія стабільних селекторів
Зміст
(locator) — це спосіб, яким автотест знаходить елемент на сторінці: кнопку, поле, рядок таблиці. Виглядає як дрібниця — рядок на кшталт #submit-btn чи getByRole('button') — але саме локатори визначають, скільки живуть твої тести. Поганий локатор ламається на кожному редизайні, гарний переживає роки правок верстки. Різниця між командою, у якій автотести довіряють, і командою, у якій їх щоранку «чинять», часто зводиться саме до дисципліни локаторів.
Для AQA це болюча тема з двох боків. По-перше, це найчастіша причина крихкості (brittleness): тест червоний не тому, що застосунок зламався, а тому, що розробник перейменував CSS-клас або обгорнув кнопку в ще один div. По-друге, відповіді на питання «за яким пріоритетом ти обираєш локатори?», «чому не XPath за індексом?», «що таке data-testid і навіщо він» показують, хто писав тести, які треба було підтримувати, а не викидати.
Чому локатор — це про стабільність, а не про пошук
Знайти елемент можна десятками способів. Проблема не в тому, щоб знайти його зараз, а в тому, щоб той самий рядок коду знаходив той самий елемент через півроку, після сотні у фронтенд. Локатор — це неявний контракт між тестом і застосунком: «цей елемент існує, він унікальний, і його можна впізнати ось за цією ознакою».
Ключове питання до будь-якого локатора одне: наскільки ймовірно, що ознака, за яку я чіпляюся, зміниться, поки поведінка застосунку лишається тією самою? Верстку переписують постійно: міняють теги, вкладеність, класи, порядок елементів — і при цьому кнопка «Купити» лишається кнопкою «Купити». Якщо твій локатор прив'язаний до чогось, що змінюється разом з версткою, а не разом зі змістом, він приречений.
Звідси випливає ієрархія надійності — від найстабільнішого до найкрихкішого:
| Ознака елемента | Стабільність | Чому |
|---|---|---|
Спеціальний тест-ідентифікатор (data-testid) | Найвища | Існує тільки для тестів; його не чіпає ні зміна тексту, ні зміна ролі |
Роль + доступне ім'я (getByRole) | Висока | Змінюється лише коли змінюється призначення елемента |
| Видимий текст, мітка, placeholder | Середня | Тримається, поки не переписали копірайт і не додали локалізацію |
CSS-клас, id зі змістом | Середня / низька | Класи міняють заради стилів; згенеровані id — щобілду інші |
| Позиція в DOM, індекс, XPath по дереву | Дуже низька | Ламається від будь-якої зміни вкладеності чи порядку |
Тут важливо не сплутати дві різні осі. Перша — стійкість, вона в таблиці вище: нагорі , бо Playwright прямо називає пошук за test id найстійкішим способом (тест переживе і зміну тексту, і зміну ролі), а Cypress у своїй таблиці дає data-cy вердикт «Always» проти «Depends» для пошуку за текстом. Друга вісь — близькість до користувача: тут нагорі роль і доступне ім'я, бо вони перевіряють те, що бачить і чує реальний користувач, і заразом ловлять доступності.
Рекомендований пріоритет Playwright побудований саме на другій осі: спершу getByRole, getByLabel, getByText, getByPlaceholder, потім getByTestId, і лише в крайньому разі — CSS чи XPath. Тест-ідентифікатор стоїть після user-facing локаторів не тому, що він менш стійкий, а тому, що сам по собі він нічого не каже про користувацький досвід. А логіка щодо XPath не в тому, що він «поганий сам по собі», а в тому, що він майже завжди чіпляється за структуру, а не за зміст.
Чому згенеровані й позиційні локатори ламаються першими
Два класи локаторів гарантовано підведуть — і саме їх найчастіше пишуть новачки, бо їх найлегше «намацати» через праву кнопку → Copy selector у DevTools.
Згенеровані ознаки. Сучасні й UI-фреймворки самі створюють класи та ідентифікатори, і роблять їх навмисне непередбачуваними. CSS-модулі перетворюють клас .button на .button_a1b2c3 з хешем, який змінюється щобілду. Styled-components видають класи типу .sc-bdfBwQ. Angular додає атрибути _ngcontent-abc-c12. Усе це — деталі реалізації, які не мають жодного стосунку до того, що бачить користувач. Прив'язатися до .button_a1b2c3 — це підписатися під тим, що тест червонітиме після кожного перебілду фронтенду, навіть якщо кнопка ні на йоту не змінилась. Ця ж пастка описана в главі «Кешування»: хеш у назві файлу — благо, хеш у назві класу — міна під локатором.
Позиційні локатори. Друга біда — чіплятися за місце елемента в дереві. Класичний приклад — XPath, скопійований із DevTools:
/html/body/div[2]/div/div[3]/main/section/div[1]/button
Такий локатор каже: «третій div усередині другого div усередині body…». Досить розробнику додати один обгортковий , поміняти місцями два блоки або вставити рекламний банер — і весь ланцюжок індексів з'їжджає. Тест падає з element not found, хоча кнопка на місці й прекрасно клікається руками. Те саме стосується nth-child(4), :nth-of-type і будь-якого локатора виду «четвертий рядок у списку»: він кодує не що це за елемент, а де він випадково опинився.
Обидва класи об'єднує спільна вада: вони прив'язані до того, що вільно змінюється без зміни поведінки. Тому емпіричне правило: локатор має описувати елемент так, як його описав би користувач — «кнопка Оформити замовлення», «поле Email», «рядок із товаром Ноутбук» — а не так, як його описав би парсер HTML.
data-testid як контракт із розробкою
Іноді елемент неможливо стабільно вхопити за роль чи текст: іконка без підпису, службовий контейнер, віджет, у якому текст динамічний і локалізований. Для таких випадків існує тест-ідентифікатор — спеціальний атрибут, доданий у розмітку виключно заради автоматизації. Найпоширеніша конвенція — data-testid, але команди використовують і data-test, data-qa, data-cy.
<button data-testid="checkout-submit" class="btn btn-primary sc-bdfBwQ">
Оформити замовлення
</button>
// Playwright
await page.getByTestId('checkout-submit').click();
Чому це працює краще за клас чи id? Тому що data-testid існує з єдиною метою — бути точкою зачепу для тестів. У нього немає побічного життя: його не чіпає рефакторинг стилів, не переписує зміна тексту, не зачищає оптимізатор . А головне — сам факт його наявності робить зв'язок явним. Розробник, який бачить у коді data-testid="checkout-submit", розуміє: цей атрибут комусь потрібен, його не можна безкарно видалити. Це і є контракт: команда домовляється, що ці атрибути — частина інтерфейсу застосунку, така сама, як публічний API, і ламати їх треба свідомо, а не мимохідь.
Звідси кілька важливих наслідків:
data-testidнайстійкіший, але не перший вибір. Якщо елемент має роль і доступне ім'я,getByRole('button', { name: 'Оформити замовлення' })кращий: він заразом перевіряє доступність (accessibility) і не потребує правок у продакшн-розмітці. Тест-ідентифікатор беруть тоді, коли семантичного зачепу немає або він нестабільний, — це вибір за віссю близькості до користувача, а не за стійкістю.- Це командне рішення, а не таємна зброя QA. Якщо AQA нишком чіпляється за випадкові класи, бо «розробники не додають testid», проблема не технічна, а процесна. Домовленість про тест-ідентифікатори — частина визначення готовості (Definition of Done): фіча не готова, поки її ключові елементи не мають стабільних зачепів.
- Атрибути прибирають з продакшн-білда за бажанням. Дехто вважає
data-testidсміттям у бойовій розмітці. Це вирішується конфігом бандлера, який вирізає їх у продакшні, — але рішення має бути спільним, бо вирізаний атрибут = мертвий локатор.
Окрема пастка — іменування. data-testid="button1" майже так само крихкий, як клас: незрозуміло, що це, і легко продублювати. Гарний тест-ідентифікатор описує роль елемента в сценарії: checkout-submit, login-email, cart-item-remove. Ще краще — префіксувати за компонентом, щоб уникати колізій: cart-item, cart-total, cart-checkout.
Локатори для списків, таблиць і динаміки
Досі йшлося про поодинокі елементи. Реальний біль починається там, де елементів багато й вони однакові: списки товарів, рядки таблиці, картки, повідомлення чату. Тут спокуса написати «третій рядок» найсильніша — і найшкідливіша, бо порядок і кількість елементів змінюються від запуску до запуску (інші дані, інша сортування, підвантаження на льоту).
Правильний підхід — шукати не за позицією, а за вмістом або відношенням. Замість «рядок номер 4» — «рядок, у якому є текст Ноутбук». Замість «третя кнопка Видалити» — «кнопка Видалити всередині картки товару Ноутбук».
// Погано: прив'язка до позиції — зламається від сортування чи нових даних
await page.locator('table tbody tr').nth(3).getByRole('button', { name: 'Видалити' }).click();
// Добре: спершу звужуємо до потрібного рядка за вмістом, потім діємо
const row = page.getByRole('row', { name: /Ноутбук/ });
await row.getByRole('button', { name: 'Видалити' }).click();
Ключова техніка тут — (scoping): спершу знаходимо контейнер (рядок, картку) за унікальною ознакою, потім шукаємо кнопку всередині нього. Це і читається як намір («видали рядок з ноутбуком»), і не залежить від того, скільки всього рядків і в якому вони порядку. У Playwright для цього є filter():
const item = page.getByTestId('cart-item').filter({ hasText: 'Ноутбук' });
await item.getByRole('button', { name: 'Видалити' }).click();
Playwright формулює це без винятків: nth, first і last — не рекомендовані, бо після зміни сторінки тест може клікнути не той елемент, і замість них треба будувати локатор, який однозначно ідентифікує потрібний. Наша практика індекс у вузькому випадку, коли позиція сама є частиною перевірки: «перший результат пошуку», «останнє повідомлення в чаті». Але це саме наша домовленість, а не виняток із доки — якщо позицію можна виразити через вміст чи відношення, надійніше зробити так.
Динаміка додає ще один вимір. Елемент може з'явитися не одразу: після запиту до API, анімації, ліниве підвантаження. Тут працює важлива концептуальна межа: пошук елемента й очікування елемента — різні задачі, і їх не можна змішувати через sleep. Сучасні інструменти вміють автоматично чекати, поки елемент з'явиться й стане готовим до дії (auto-waiting), тож правильний локатор + вбудоване очікування вирішують проблему без жорстких очікувань. waitForTimeout(3000) — антипатерн: на швидкій машині це змарновані секунди, на повільній — все одно . Механіку очікувань детально розбирає глава «Практичні сценарії AQA: флак і синхронізація», а таксономію самого — глава «Флакі-тести: причини, діагностика, лікування» цього розділу.
Одне джерело істини: локатори в page object
Навіть ідеальний локатор стає проблемою, якщо він розсипаний по десятках тестів копіпастом. Коли кнопку «Оформити замовлення» шукають у п'ятнадцяти файлах через page.getByTestId('checkout-submit'), зміна цього одного атрибута перетворюється на полювання по всьому репозиторію — і один пропущений файл дає червоний тест.
Тому діє принцип одного джерела істини (single source of truth): кожен локатор описаний рівно в одному місці, а тести звертаються до нього через ім'я. Канонічна форма цього — (об'єкт сторінки): клас, який тримає локатори й дії однієї сторінки чи компонента. Тест каже що робити, page object знає як знайти елементи.
// checkout.page.ts — єдине місце, де живуть локатори сторінки
export class CheckoutPage {
constructor(private readonly page: Page) {}
readonly submitButton = this.page.getByTestId('checkout-submit');
readonly emailField = this.page.getByLabel('Email');
async submit() {
await this.submitButton.click();
}
}
// у тесті — жодного «сирого» селектора, лише наміри
const checkout = new CheckoutPage(page);
await checkout.emailField.fill('qa@example.com');
await checkout.submit();
Тепер, якщо data-testid зміниться, правка потрібна в одному рядку одного файлу — і всі п'ятнадцять тестів полагоджені разом. Це і є головна цінність шаблону: не «красива архітектура», а різко дешевша підтримка при зміні верстки. Повний розбір page object — від класики до компонентного підходу — у главі «Page Object: від класики до компонентів»; тут важливо запам'ятати сам інваріант: локатор не має жити в тілі тесту.
Той самий принцип працює й поза UI: у API-клієнтах й заголовки теж мають жити в одному місці, а не дублюватися по тестах. Локатор для UI — це те саме, що URL для API: технічна адреса, яку тримають в одному сховищі й не розмазують по коду.
Типові помилки
- Виглядає як надійний локатор, бо DevTools його згенерував — а насправді це найкрихкіший варіант. Copy → Copy selector у браузері майже завжди видає довгий CSS/XPath за позицією й згенерованими класами. Він працює зараз і ламається від першої ж зміни верстки. Інструмент згенерував — не означає «стабільний».
- Виглядає як стабільний
id, а насправді він згенерований щобілду.id="mui-4821",id="input-a1b2"— такі ідентифікатори видає фреймворк, і наступний білд дасть інші. Прив'язка доidвиправдана лише коли він осмислений і заданий руками (id="login-form"), а не автогенерований. - Виглядає як точний локатор
nth(3), а насправді він кодує випадковий порядок. Рядок за фіксованим індексом зелений, поки дані ті самі й сортування те саме. Змінили чи додали запис — іnth(3)показує вже інший рядок (до того ж індекс тут відлічується з нуля). Дока Playwright не рекомендує ці методи взагалі; шукай за вмістом, а індекс лишай на випадок, коли позиція сама є перевіркою, — це вже наша домовленість. - Виглядає як чисте очікування, а насправді це замаскований флак.
waitForTimeoutперед пошуком елемента «стабілізує» тест на твоїй машині й розсипається в CI під навантаженням. Проблема не в локаторі, а в підміні очікування паузою; лікується , а не більшою паузою. - Виглядає як «розробники не дають testid», а насправді це процесний борг. Полювання за випадковими класами — симптом того, що домовленості про тест-ідентифікатори немає. Це не привід писати крихкі локатори, а привід внести тест-ідентифікатори в Definition of Done.
- Виглядає як , а насправді локатор скопійований у двадцять файлів. Той самий селектор у тілі кожного тесту — це не «просто й прозоро», а двадцять точок при одній зміні верстки. Локатор має бути в page object в одному екземплярі.
Підсумок
- Локатор — це контракт про стабільність, а не спосіб «намацати» елемент зараз. Головне питання до нього: чи зміниться ознака, поки поведінка застосунку та сама.
- Пріоритет локаторів — політика, а не смак: спершу роль і доступне ім'я (
getByRole), потім видимий текст і мітки, потімdata-testid, і лише в крайньому разі CSS/XPath. Це вісь близькості до користувача; за стійкістю нагорі стоїть самеdata-testid. - Згенеровані класи/
idі позиційні XPath/nthламаються першими, бо прив'язані до реалізації й порядку, а не до змісту. data-testid— це командний контракт: стабільний зачіп, який змінюють свідомо; тому він потрапляє в Definition of Done, а не додається AQA нишком.- Кожен локатор живе в одному місці (page object) — одна правка чинить усі тести; локатор у тілі тесту — борг, який вистрелить при першому редизайні.
Можливі питання
- «За яким пріоритетом ти обираєш локатори?» Головне питання теми. Сильна відповідь називає рекомендований порядок — роль/ім'я → текст/мітка →
data-testid→ CSS/XPath — і одразу розводить дві осі: цей порядок побудований на близькості до користувача (заразом перевіряється доступність), а за самою стійкістю нагорі стоїтьdata-testid, і саме так його оцінюють доки Playwright і Cypress. Слабка відповідь зводиться до «я беру XPath, бо він завжди працює». - «Чому XPath за індексом (
div[2]/div[3]) — погана ідея?» Перевіряють розуміння крихкості. Ключове: такий локатор кодує позицію в дереві, а не сам елемент, тож будь-яка зміна вкладеності чи порядку його ламає, хоча елемент на місці. Плюс — незрозуміло, що саме він шукає. - «Що таке
data-testidі навіщо він, якщо є класи таid?» Дивляться, чи розумієш ти ідею контракту. Хороша відповідь: це атрибут виключно для тестів, у нього немає побічного життя (стилі, текст, оптимізатор його не чіпають), і його наявність робить залежність явною — розробник не видалить його випадково. Згадай, що це командна домовленість і що роль/ім'я все одно кращі, коли доступні. - «Як знайти кнопку "Видалити" в потрібному рядку таблиці, якщо таких кнопок багато?» Практична задача на списки. Правильно: спершу звузити до рядка за унікальним вмістом (
getByRole('row', { name: /Ноутбук/ })абоfilter({ hasText })), потім шукати кнопку всередині нього — а не братиnth(3). - «Тест впав з "element not found", хоча руками все клікається. Твої дії?» Тут перевіряють діагностику. Сильний хід: спершу відрізнити крихкий локатор (згенерований клас, з'їхав індекс) від проблеми синхронізації (елемент ще не з'явився) — і не «лікувати» це
sleep. Слабкий кандидат одразу додає паузу. - «Навіщо тримати локатори в page object, а не в тесті?» Питання про підтримуваність. Одне джерело істини: зміна верстки = одна правка замість полювання по репозиторію; тест читається як намір, а не як CSS.
Джерела
Чому локатор — це про стабільність, а не про пошук
- Playwright — Locators — рекомендований пріоритет: user-facing атрибути й явні контракти, інтерактивні елементи за роллю разом із доступним імʼям; CSS і XPath —
not recommended, бо DOM часто змінюється. - Playwright — Best Practices — механізм крихкості одним реченням: опора на структуру DOM ламає тест від зміни CSS-класу дизайнером.
- Cypress — Best Practices — друга вісь таблиці, задана явною шкалою рекомендованості:
data-cy— «Always», пошук за текстом — «Depends», тег і клас — «Never». - Testing Library — Guiding Principles — принцип, з якого виріс пріоритет ролей: що більше тести схожі на те, як користуються ПЗ, то більше довіри вони дають.
- W3C — Accessible Rich Internet Applications (WAI-ARIA) — чому роль стабільна: роль це тип елемента, вона не змінюється з часом чи діями користувача, на відміну від станів і властивостей.
Чому згенеровані й позиційні локатори ламаються першими
- Cypress — Best Practices — дві названі причини крихкості: «Your application may use dynamic classes or ID's that change» і «Your selectors break from development changes to CSS styles or JS behavior»; звідси прямі приписи не цілитися в
id,class,tag. - Playwright — Locators — чому позиційний доступ ненадійний:
first()/last()/nth()це вихід зі строгого режиму й «use this method with caution», бо на зміненій сторінці локатор вкаже на зовсім інший елемент. - Playwright — Best Practices — та сама теза з боку структури: опора на DOM робить тести крихкими.
data-testid як контракт із розробкою
- Playwright — Locators — пошук за test id названо найстійкішим (тест переживе зміну тексту й ролі), але не user-facing; за замовчуванням береться
data-testid, і атрибут можна переналаштувати в конфігу. - Cypress — Best Practices — інша рекомендація в іншого інструмента:
data-*як основна стратегія, щоб ізолювати селектори від змін CSS і JS і явно позначити елемент як використовуваний тестами. - MDN — Use data attributes — чому саме
data-*: це штатний механізм HTML для додаткових даних на семантичному елементі, без нестандартних атрибутів і хаків.
Локатори для списків, таблиць і динаміки
- Playwright — Locators — механіка звуження: локатори строгі й кидають виняток на кількох збігах, а фільтрувальний локатор must бути відносним до вихідного й шукатися від його збігу, а не від кореня документа.
- Playwright — Actionability — межа «пошук проти очікування»: перед кожною дією інструмент сам виконує перевірки придатності (видимий, стабільний, отримує події, увімкнений) і чекає, поки вони пройдуть.
- Playwright —
page.waitForTimeout()— метод позначено якDiscouraged, а правило сформульовано прямо: «Never wait for timeout in production. Tests that wait for time are inherently flaky».
Одне джерело істини: локатори в page object
- Selenium — Page object models — наскрізний інваріант: «there is only one place in your test suite with knowledge of the structure of the HTML of a particular (part of a) page».
- ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: цінність сформульована як «updates in only one place, the locator inside a page model» замість правки локаторів у кожному тесті.
- Playwright — Page Object Models — те саме з боку інструмента: page object спрощує підтримку, бо захоплює селектори елементів в одному місці.
Відео до теми
Що таке локатор і чому кажуть, що він про стабільність, а не про пошук?
— це вираз, яким тест указує, який саме елемент сторінки йому потрібен: конкретну кнопку, поле, комірку таблиці. Знайти елемент технічно нескладно — способів десятки. Складність в іншому: щоб той самий рядок коду впізнавав той самий елемент і сьогодні, і через півроку сотні у фронтенд. Тому на локатор дивляться як на неявний контракт із застосунком — «цей елемент існує, він один такий, і його впізнають ось за цією ознакою». Головне питання до будь-якого локатора не «чи знайду я його зараз», а «чи зміниться ознака, за яку я чіпляюся, поки поведінка застосунку та сама». Саме ця оптика й визначає, скільки живуть тести без ручного лагодження.
За яким пріоритетом обирати локатори?
Це головне питання теми, і сильна відповідь називає рекомендований порядок. Спершу — роль і доступне ім'я (getByRole), бо вони змінюються лише разом із призначенням елемента. Далі — видимий текст, мітка, placeholder (getByText, getByLabel): стабільні, поки не переписали копірайт чи не додали локалізацію. Потім спеціальний data-testid. І тільки в крайньому разі — CSS-селектор чи XPath. Логіка проста: цей порядок побудований на близькості до користувача, бо user-facing локатор заразом перевіряє доступність. Не плутай його з віссю стійкості: за нею нагорі стоїть саме data-testid — Playwright називає пошук за test id найстійкішим, а Cypress дає data-cy вердикт «Always» проти «Depends» для пошуку за текстом. Слабка відповідь звучить як «беру XPath, бо він завжди спрацьовує» — це видає людину, яка не підтримувала свої тести довше за спринт.
Чому getByRole вважають найнадійнішим варіантом?
Роль (button, link, textbox, row) плюс доступне ім'я описують призначення елемента, а не його оформлення чи місце в дереві. Розробник вільно міняє тег, класи, вкладеність, переносить кнопку в інший — доки це кнопка «Купити», getByRole('button', { name: 'Купити' }) її знаходить. Побічний бонус: такий локатор заразом опирається на дерево доступності (accessibility), тобто ще й побіжно перевіряє, що елемент коректно проговорюється скрінрідером. Тому пошук за роллю ближчий за все до того, як елемент сприймає жива людина, — і саме тому він перший у рекомендованому порядку.
Чому XPath виду /html/body/div[2]/div/div[3]/button — погана ідея?
Такий локатор кодує не сам елемент, а його випадкову позицію в дереві: «третій div усередині другого div…». Досить розробнику додати один обгортковий контейнер, поміняти місцями два блоки чи вставити банер — і весь ланцюжок індексів з'їжджає. Тест падає з element not found, хоча кнопка на місці й чудово клікається руками. Друга біда — нечитабельність: із такого рядка неможливо зрозуміти, що саме він шукає. Такі XPath найчастіше беруть із браузера через Copy → Copy selector, і саме тому вони й крихкі — інструмент генерує шлях за структурою, а не за змістом.
Що не так зі згенерованими класами на кшталт .button_a1b2c3 чи .sc-bdfBwQ?
Це деталі реалізації, не пов'язані з тим, що бачить користувач. CSS-модулі домішують до класу хеш, який змінюється щобілду; styled-components видають класи типу .sc-bdfBwQ; Angular додає атрибути _ngcontent-abc-c12. Прив'язатися до такого — це підписатися під тим, що тест червонітиме після кожного перебілду фронтенду, навіть якщо кнопка не змінилась ні на піксель. Проблема та сама, що з позиційним XPath: локатор чіпляється за те, що вільно міняється без зміни поведінки. Правило-протиотрута: описуй елемент так, як його описав би користувач, а не парсер HTML.
Що таке data-testid і навіщо він, якщо вже є class та id?
data-testid — атрибут, доданий у розмітку виключно заради автоматизації; поширені варіанти конвенції — data-test, data-qa, data-cy. Він працює краще за клас чи id, бо не має побічного життя: його не чіпає рефакторинг стилів, не переписує зміна тексту, не зачищає оптимізатор . Клас існує заради вигляду й тому міняється заради вигляду; тест-ідентифікатор існує заради тесту й міняється лише свідомо. А ще сам факт його наявності робить залежність явною — розробник, побачивши data-testid="checkout-submit", розуміє, що атрибут комусь потрібен і видаляти його не можна. Це і є контракт: команда домовляється, що такі атрибути — частина інтерфейсу застосунку, як публічний API.
data-testid — це завжди найкращий вибір?
Ні, хоч за стійкістю він і найвищий. Якщо елемент має роль і доступне ім'я, getByRole('button', { name: 'Оформити замовлення' }) кращий: він не потребує додаткової розмітки в продакшні й побіжно перевіряє доступність. Тест-ідентифікатор беруть тоді, коли семантичного зачепу немає або він нестабільний: іконка без підпису, службовий контейнер, віджет із динамічним локалізованим текстом. Тобто рекомендований порядок лишається — роль/ім'я → текст/мітка → data-testid, — а не «скрізь чіпляємось за testid, бо так простіше».
Як правильно іменувати тест-ідентифікатори?
Погана назва майже так само крихка, як клас. data-testid="button1" нічого не каже про елемент і легко дублюється по сторінці. Гарний тест-ідентифікатор описує роль елемента в сценарії: checkout-submit, login-email, cart-item-remove. Щоб уникати колізій, ім'я корисно префіксувати за компонентом — cart-item, cart-total, cart-checkout. Тоді локатор читається як намір, а не як випадковий ярлик, і два розробники не почеплять один і той самий testid на різні кнопки.
Хтось каже, що data-testid — це сміття в бойовій розмітці. Що відповісти?
Атрибути справді можна вирізати з продакшн-білда — це налаштовується конфігом , який лишає їх у тестових білдах і прибирає в бойовому. Але тут є пастка: вирізаний атрибут дорівнює мертвому локатору, тому рішення має бути спільним і узгодженим із QA, а не мовчазним рефакторингом одного розробника. Якщо команда домовилась вирізати testid у проді — тести мають ганятися на тому білді, де вони ще є. Отже, це не технічна дрібниця, а питання процесу: тест-ідентифікатори частина контракту, і поводитися з ними треба свідомо з обох боків.
Як знайти кнопку «Видалити» в потрібному рядку таблиці, коли таких кнопок багато?
Не через nth(3). Індекс кодує випадковий порядок: змінили сортування, додали запис, підвантажили нові дані — і третій рядок уже інший. Правильний хід — (scoping): спершу знайти сам рядок за унікальним вмістом, потім шукати кнопку всередині нього. У Playwright це page.getByRole('row', { name: /Ноутбук/ }), а далі .getByRole('button', { name: 'Видалити' }); альтернатива — filter({ hasText: 'Ноутбук' }) на контейнері. Так локатор читається як намір («видали рядок з ноутбуком») і не залежить ні від кількості рядків, ні від їхнього порядку.
Коли наша практика допускає nth(), first() чи last()?
Дока Playwright не рекомендує ці методи взагалі: після зміни сторінки тест може клікнути не той елемент, тож замість індексу радять будувати локатор, який однозначно ідентифікує потрібний. Наша домовленість лишає для них один вузький випадок — коли позиція сама є частиною перевірки. «Перший результат пошуку», «останнє повідомлення в чаті», «верхній рядок відсортованого списку» — тут порядок несе бізнес-зміст, і прив'язка до нього виправдана й навіть необхідна. Крихкість починається там, де індексом підміняють ідентичність: nth(3) замість «рядок із товаром X» кодує не що це за елемент, а де він випадково опинився. Тобто питання не в самому nth, а в тому, чи позиція — це предмет тесту, чи випадковість верстки.
Тест упав з «element not found», хоча руками все клікається. Твої дії?
Спершу відрізнити два різні діагнози, бо лікуються вони по-різному. Перший — крихкий локатор: згенерований клас перебілдився, з'їхав позиційний індекс, зник testid; тут елемент на місці, а ознака протухла — рятує стабільніший локатор. Другий — синхронізація: елемент іще не з'явився на момент пошуку (запит до API не завершився, триває анімація, ліниве підвантаження); тут лікує , а не зміна селектора. Головне — не «глушити» проблему через sleep: пауза замаскує обидва випадки й перетвориться на . Слабкий кандидат одразу додає waitForTimeout і йде далі; сильний спершу з'ясовує, який саме з двох діагнозів перед ним.
Чому waitForTimeout(3000) — антипатерн, і чим його замінити?
Бо це замаскований . Фіксована пауза підганяється під одну машину: на швидкій це змарновані секунди, на повільній чи під навантаженням у CI — все одно замало, і тест падає. Пошук елемента й очікування елемента — різні задачі, і змішувати їх через sleep не можна. У сучасних інструментів є автоочікування: getByRole(...) у Playwright сам дочекається, поки елемент з'явиться й стане готовим до дії, перш ніж клікнути. Тому правильний локатор плюс вбудоване очікування знімають потребу в , а waitForTimeout лишається хіба що для рідкісних діагностичних потреб, не для бойових тестів.
Навіщо тримати локатори в page object, а не в тілі тесту?
Заради дешевої підтримки при зміні верстки. Якщо кнопку «Оформити замовлення» шукають через getByTestId('checkout-submit') у п'ятнадцяти файлах копіпастом, зміна цього атрибута перетворюється на полювання по всьому репозиторію, і один пропущений файл дає червоний тест. Принцип одного джерела істини (single source of truth) каже: кожен локатор описаний рівно в одному місці, а тести звертаються до нього через ім'я. Канонічна форма — : клас тримає локатори й дії однієї сторінки, тест каже що робити, page object знає як знайти. Тоді зміна testid — це правка в одному рядку, і всі тести полагоджені разом.
Що спільного між локатором для UI і URL для API?
Обидва — технічна адреса, за якою тест звертається до системи, і обидва мають жити в одному місці, а не дублюватися по коду. Для UI це локатор у page object; для API — і заголовки в одному клієнті чи утиліті. Логіка ідентична: коли адреса змінюється, хочеться правити її в одному екземплярі, а не шукати двадцять копій по тестах. Тому «розмазаний по коду локатор» і «захардкоджений у кожному тесті URL» — це один і той самий борг, який вистрелить при першій же зміні.
Розробники не додають data-testid, доводиться чіплятися за випадкові класи. Це нормально?
Це не технічна проблема, а процесний борг, і крихкими локаторами його не лікують. Полювання за згенерованими класами — симптом того, що в команді немає домовленості про тест-ідентифікатори. Правильний хід — внести стабільні зачепи в Definition of Done: фіча не вважається готовою, поки її ключові елементи не мають надійних локаторів. Це командне рішення, а не таємна зброя QA, яку той нишком припасовує до чужого коду. Сильна відповідь тут показує, що кандидат бачить різницю між «поганий код» і «поганий процес» і не намагається закрити другу дірку першою.
Три кейси, де вибір вирішує, переживе тест наступний редизайн чи посиплеться на рівному місці: перепис крихкого селектора з DevTools на стабільний, пошук потрібної кнопки серед десятків однакових і винесення локаторів у . Скрізь — що саме крихке і як його вилікувати.
Кейс 1. Той самий елемент, п'ять локаторів різної стійкості
Верстальник дав кнопку оформлення замовлення. Ось як її можна вхопити — від найкрихкішого до найстійкішого варіанта (це вісь стійкості; рекомендований порядок вибору — окрема вісь, і там getByRole іде першим). Розмітка:
<div class="checkout-footer sc-bdfBwQ">
<button id="mui-4821" class="btn btn-primary sc-hKwDvalue" data-testid="checkout-submit">
Оформити замовлення
</button>
</div>
| Локатор | Стійкість | Що його ламає |
|---|---|---|
//div[2]/div/button | Дуже низька | Будь-який новий контейнер або зміна порядку блоків |
.sc-hKwDvalue | Низька | Наступний білд styled-components дасть інший хеш |
#mui-4821 | Низька | id згенерований фреймворком — щобілду новий |
getByRole('button', { name: 'Оформити замовлення' }) | Висока | Зміна призначення кнопки або її видимого тексту |
getByTestId('checkout-submit') | Найвища | Тільки свідома зміна тест-ідентифікатора |
// Погано: скопійовано з DevTools через Copy selector — чіпляється за позицію й хеш
await page.locator('//div[2]/div/button').click();
await page.locator('.sc-hKwDvalue').click();
// Найживучіше: тест-атрибут, його не зрушить ні зміна тексту, ні зміна ролі
await page.getByTestId('checkout-submit').click();
// Перший вибір за рекомендованим порядком: роль + видиме ім'я, заразом перевіряє доступність
await page.getByRole('button', { name: 'Оформити замовлення' }).click();
Що дивитися і чому:
- Три верхні рядки таблиці «працюють зараз» і брешуть про стабільність. Позиційний XPath, згенерований клас і фреймворковий
idживі рівно до наступної правки верстки чи перебілду — елемент лишиться на місці, а локатор перестане його знаходити. getByRoleвиграє, коли елемент має видиме ім'я. Він не потребує додаткової розмітки в продакшні й побіжно свідчить, що кнопка коректно проговорюється скрінрідером.data-testid— найстійкіший, але нижчий у рекомендованому порядку. Його беруть, коли ролі й імені немає чи вони нестабільні: іконка без підпису, службовий , динамічний локалізований текст. Нижчий він не через крихкість, а через те, що нічого не каже про користувацький досвід.
Кейс 2. Кнопка «Видалити» в потрібному рядку серед десятків однакових
Таблиця кошика: багато рядків, у кожному своя кнопка «Видалити». Спокуса взяти «третю кнопку» найсильніша — і найшкідливіша, бо порядок і кількість рядків змінюються від запуску до запуску.
<table>
<tbody>
<tr data-testid="cart-item"><td>Мишка</td><td>...</td><td><button>Видалити</button></td></tr>
<tr data-testid="cart-item"><td>Ноутбук</td><td>...</td><td><button>Видалити</button></td></tr>
<tr data-testid="cart-item"><td>Клавіатура</td><td>...</td><td><button>Видалити</button></td></tr>
</tbody>
</table>
// Погано: прив'язка до позиції — зламається від сортування чи нових даних
await page.locator('table tbody tr').nth(1).getByRole('button', { name: 'Видалити' }).click();
// Добре: спершу звужуємо до рядка за унікальним вмістом, потім діємо всередині нього
const row = page.getByRole('row', { name: /Ноутбук/ });
await row.getByRole('button', { name: 'Видалити' }).click();
// Той самий підхід через filter — коли зачіп це data-testid контейнера
const item = page.getByTestId('cart-item').filter({ hasText: 'Ноутбук' });
await item.getByRole('button', { name: 'Видалити' }).click();
Що дивитися і чому:
nth(1)кодує випадковий порядок, а не ідентичність. Змінили сортування чи — і другий рядок уже інший товар; тест зелений, поки дані ті самі, і бреше, щойно вони змінюються.- читається як намір.
row.getByRole('button', ...)прямо каже «видали рядок з ноутбуком» і не залежить ні від кількості рядків, ні від їхнього порядку. - Індекс наша практика лишає лише там, де позиція — предмет тесту. «Перший результат пошуку», «останнє повідомлення в чаті» — тут
first()/last()несуть бізнес-зміст. Сама дока Playwright цих методів не рекомендує взагалі й радить будувати однозначний локатор.
Кейс 3. Page object: одна правка замість полювання по репозиторію
Локатор getByTestId('checkout-submit'), скопійований у п'ятнадцять тестів, — це п'ятнадцять точок при одній зміні розмітки. Виносимо його в одне місце.
// checkout.page.ts — єдине місце, де живуть локатори сторінки
import { type Page } from '@playwright/test';
export class CheckoutPage {
constructor(private readonly page: Page) {}
readonly emailField = this.page.getByLabel('Email');
readonly submitButton = this.page.getByRole('button', { name: 'Оформити замовлення' });
async submit(email: string) {
await this.emailField.fill(email);
await this.submitButton.click();
}
}
// checkout.spec.ts — у тесті жодного «сирого» селектора, лише наміри
import { test } from '@playwright/test';
import { CheckoutPage } from './checkout.page';
test('оформлення замовлення гостем', async ({ page }) => {
const checkout = new CheckoutPage(page);
await page.goto('/checkout');
await checkout.submit('qa@example.com');
});
Що дивитися і чому:
- Зміна зачепу — правка в одному рядку. Якщо
data-testidчи текст кнопки зміниться, чиниться один файл, а всі тести, що ходять черезCheckoutPage, лагодяться разом. - Тест читається як сценарій, а не як CSS.
checkout.submit(...)описує намір; як знайти елементи — знає page object, і це деталь, якій не місце в тілі тесту. - Той самий принцип працює й для API. й заголовки теж тримають в одному клієнті: локатор для UI — це те саме, що URL для API, технічна адреса в одному сховищі, а не розмазана по коду.
Локатор як контракт стабільності
- Розумію, що — це неявний контракт із застосунком, а не просто «спосіб намацати елемент зараз».
- Можу сформулювати головне питання до будь-якого локатора: чи зміниться ознака, за яку я чіпляюся, поки поведінка застосунку та сама.
- Знаю правило-протиотруту: локатор має описувати елемент так, як його описав би користувач, а не парсер HTML.
Пріоритет вибору локаторів
- Пам'ятаю рекомендований порядок: роль/ім'я → текст/мітка/
placeholder→data-testid→ CSS/XPath — і що це вісь близькості до користувача. - Можу пояснити, чому
getByRole— перший вибір: він описує призначення елемента й побіжно спирається на дерево доступності. - Розумію, що пріоритет локаторів — це політика, а не смак, і не плутаю дві осі: за стійкістю нагорі
data-testid, за близькістю до користувача — роль і доступне ім'я.
Чому крихкі локатори ламаються першими
- Знаю різницю між згенерованими ознаками (
.button_a1b2c3,.sc-bdfBwQ,_ngcontent-abc) і осмисленими: перші міняються щобілду. - Можу пояснити, чому позиційний XPath (
div[2]/div[3]),nth-childі скопійований через Copy → Copy selector у DevTools локатор крихкі: кодують місце в дереві чи згенерований клас, а не сам елемент, хоч і «працюють зараз». - Знаю, чому
idвидуmui-4821ненадійний, а заданий рукамиid="login-form"— прийнятний.
data-testid як контракт із розробкою
- Можу пояснити, чому
data-testidстабільніший за клас: у нього немає побічного життя (стилі, текст, оптимізатор його не чіпають); знаю синоніми конвенції —data-test,data-qa,data-cy. - Розумію, що починають не з нього, хоч за стійкістю він і найвищий: за наявності ролі й імені
getByRoleкращий. - Можу відрізнити гарне ім'я (
checkout-submit,cart-item-remove) від крихкого (button1) і знаю про префікс за компонентом. - Розумію, що тест-ідентифікатори — командна домовленість і частина Definition of Done (а не таємна зброя QA); їх можна вирізати з продакшн-збірки конфігом, але це спільне рішення, бо вирізаний testid = мертвий локатор.
Списки, таблиці, динаміка
- Можу пояснити (scoping): спершу знайти рядок/картку за вмістом, потім шукати кнопку всередині нього.
- Знаю, як застосувати
filter({ hasText }), щоб не чіплятися за позицію в списку. - Розумію, що дока Playwright
nth/first/lastне рекомендує, а наша практика лишає їх лише там, де позиція сама є частиною перевірки. - Розумію, що пошук елемента й очікування — різні задачі, які не змішують через
sleep:waitForTimeout(3000)антипатерн, його замінює .
Одне джерело істини: page object
- Розумію принцип single source of truth: кожен локатор описаний рівно в одному місці й не має жити в тілі тесту — інакше зміна верстки коштує полювання по репозиторію.
- Знаю, що тримає локатори й дії, а тест каже лише що робити, не як знайти.
- Розумію аналогію: локатор для UI — це те саме, що URL і заголовки для API, і жити вони мають в одному сховищі.
Квіз
Перед стартом
- Питань: 15
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Локатор — це про пошук чи про стабільність?



