Локатори: стратегія стабільних селекторів
Зміст
Локатор (locator) — це спосіб, яким автотест знаходить елемент на сторінці: кнопку, поле, рядок таблиці. Виглядає як дрібниця — рядок на кшталт #submit-btn чи getByRole('button') — але саме локатори визначають, скільки живуть твої тести. Поганий локатор ламається на кожному редизайні, гарний переживає роки правок верстки. Різниця між командою, у якій автотести довіряють, і командою, у якій їх щоранку «чинять», часто зводиться саме до дисципліни локаторів.
Для AQA це болюча тема з двох боків. По-перше, це найчастіша причина крихкості (brittleness): тест червоний не тому, що застосунок зламався, а тому, що розробник перейменував CSS-клас або обгорнув кнопку в ще один div. По-друге, це улюблене питання співбесід рівня junior і middle: «за яким пріоритетом ти обираєш локатори?», «чому не XPath за індексом?», «що таке data-testid і навіщо він». Хто відповідає впевнено, показує, що писав тести, які треба було підтримувати, а не викидати.
Чому локатор — це про стабільність, а не про пошук
Знайти елемент можна десятками способів. Проблема не в тому, щоб знайти його зараз, а в тому, щоб той самий рядок коду знаходив той самий елемент через півроку, після сотні комітів у фронтенд. Локатор — це неявний контракт між тестом і застосунком: «цей елемент існує, він унікальний, і його можна впізнати ось за цією ознакою».
Ключове питання до будь-якого локатора одне: наскільки ймовірно, що ознака, за яку я чіпляюся, зміниться, поки поведінка застосунку лишається тією самою? Верстку переписують постійно: міняють теги, вкладеність, класи, порядок елементів — і при цьому кнопка «Купити» лишається кнопкою «Купити». Якщо твій локатор прив'язаний до чогось, що змінюється разом з версткою, а не разом зі змістом, він приречений.
Звідси випливає ієрархія надійності — від найстабільнішого до найкрихкішого:
| Ознака елемента | Стабільність | Чому |
|---|---|---|
Роль + доступне ім'я (getByRole) | Висока | Змінюється лише коли змінюється призначення елемента |
| Видимий текст, мітка, placeholder | Висока / середня | Стабільний, поки не переписали копірайт чи не додали локалізацію |
Спеціальний тест-атрибут (data-testid) | Висока | Існує тільки для тестів, змінюється свідомо |
CSS-клас, id зі змістом | Середня / низька | Класи міняють заради стилів; згенеровані id — щобілду інші |
| Позиція в DOM, індекс, XPath по дереву | Дуже низька | Ламається від будь-якої зміни вкладеності чи порядку |
Playwright невипадково рекомендує саме такий пріоритет: спершу getByRole, getByLabel, getByText, getByPlaceholder, потім getByTestId, і лише в крайньому разі — CSS чи XPath. Логіка не в тому, що 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();
Індекс (nth, first, last) — легітимний лише тоді, коли позиція сама є частиною перевірки: «перший результат пошуку», «останнє повідомлення в чаті». Тут порядок — це бізнес-зміст, а не випадковість верстки, і прив'язка до нього виправдана.
Динаміка додає ще один вимір. Елемент може з'явитися не одразу: після запиту до API, анімації, ліниве підвантаження. Тут працює важлива концептуальна межа: пошук елемента й очікування елемента — різні задачі, і їх не можна змішувати через sleep. Сучасні інструменти вміють автоматично чекати, поки елемент з'явиться й стане готовим до дії (auto-waiting), тож правильний локатор + вбудоване очікування вирішують проблему без хардкодних пауз. Хардкодний waitForTimeout(3000) — антипатерн: на швидкій машині це змарновані секунди, на повільній — все одно флак. Механіку очікувань детально розбирає глава «Практичні сценарії AQA: флак і синхронізація», а таксономію самого флаку — глава «Флакі-тести: причини, діагностика, лікування» цього розділу.
Одне джерело правди: локатори в page object
Навіть ідеальний локатор стає проблемою, якщо він розсипаний по десятках тестів копіпастом. Коли кнопку «Оформити замовлення» шукають у п'ятнадцяти файлах через page.getByTestId('checkout-submit'), зміна цього одного атрибута перетворюється на полювання по всьому репозиторію — і один пропущений файл дає червоний тест.
Тому діє принцип одного джерела правди (single source of truth): кожен локатор описаний рівно в одному місці, а тести звертаються до нього через ім'я. Канонічна форма цього — page object (об'єкт сторінки): клас, який тримає локатори й дії однієї сторінки чи компонента. Тест каже що робити, 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)показує вже інший рядок (до того ж індекс тут відлічується з нуля). Шукай за вмістом, індекс — лише коли позиція сама є перевіркою. - Виглядає як чисте очікування, а насправді це замаскований флак.
waitForTimeoutперед пошуком елемента «стабілізує» тест на твоїй машині й розсипається в CI під навантаженням. Проблема не в локаторі, а в підміні очікування паузою; лікується авточеканням, а не більшою паузою. - Виглядає як «розробники не дають testid», а насправді це процесний борг. Полювання за випадковими класами — симптом того, що домовленості про тест-атрибути немає. Це не привід писати крихкі локатори, а привід внести тест-атрибути в Definition of Done.
- Виглядає як DRY, а насправді локатор скопійований у двадцять файлів. Той самий селектор у тілі кожного тесту — це не «просто й прозоро», а двадцять точок відмови при одній зміні верстки. Локатор має бути в page object в одному екземплярі.
Підсумок
- Локатор — це контракт про стабільність, а не спосіб «намацати» елемент зараз. Головне питання до нього: чи зміниться ознака, поки поведінка застосунку та сама.
- Пріоритет локаторів — політика, а не смак: спершу роль і доступне ім'я (
getByRole), потім видимий текст і мітки, потімdata-testid, і лише в крайньому разі CSS/XPath. - Згенеровані класи/
idі позиційні XPath/nthламаються першими, бо прив'язані до реалізації й порядку, а не до змісту. data-testid— це командний контракт: стабільний зачіп, який змінюють свідомо; тому він потрапляє в Definition of Done, а не додається AQA нишком.- Кожен локатор живе в одному місці (page object) — одна правка чинить усі тести; локатор у тілі тесту — борг, який вистрелить при першому редизайні.
Що питають на співбесіді
- «За яким пріоритетом ти обираєш локатори?» Головне питання теми. Сильна відповідь називає ієрархію від стабільного до крихкого — роль/ім'я → текст/мітка →
data-testid→ CSS/XPath — і пояснює чому саме так: чим ближче ознака до того, що бачить користувач, тим рідше вона змінюється без зміни поведінки. Слабка відповідь зводиться до «я беру 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 — офіційна документація й рекомендований пріоритет локаторів (
getByRole,getByLabel,getByTestIdтощо), звуження контексту,filter(). - Playwright — Best Practices: Locators — чому обирати user-facing локатори й уникати прив'язки до деталей реалізації.
- Testing Library — Guiding Principles — принцип «шукай елементи так, як їх шукає користувач», що лежить в основі пріоритету ролей і доступних імен.
- MDN — ARIA roles — ролі елементів, на яких базується
getByRole; зв'язок стабільних локаторів із доступністю. - MDN — Using data attributes — атрибути
data-*як механізм, на якому стоять тест-ідентифікатори.
Що таке локатор і чому кажуть, що він про стабільність, а не про пошук?
Локатор — це вираз, яким тест указує, який саме елемент сторінки йому потрібен: конкретну кнопку, поле, комірку таблиці. Знайти елемент технічно нескладно — способів десятки. Складність в іншому: щоб той самий рядок коду впізнавав той самий елемент і сьогодні, і через півроку сотні комітів у фронтенд. Тому на локатор дивляться як на неявний контракт із застосунком — «цей елемент існує, він один такий, і його впізнають ось за цією ознакою». Головне питання до будь-якого локатора не «чи знайду я його зараз», а «чи зміниться ознака, за яку я чіпляюся, поки поведінка застосунку та сама». Саме ця оптика й визначає, скільки живуть тести без ручного лагодження.
За яким пріоритетом обирати локатори?
Це головне питання теми, і сильна відповідь називає ієрархію від стабільного до крихкого. Спершу — роль і доступне ім'я (getByRole), бо вони змінюються лише разом із призначенням елемента. Далі — видимий текст, мітка, placeholder (getByText, getByLabel): стабільні, поки не переписали копірайт чи не додали локалізацію. Потім спеціальний тест-атрибут data-testid. І тільки в крайньому разі — CSS-селектор чи XPath. Логіка проста: чим ближче ознака до того, що бачить і читає користувач, тим рідше вона змінюється без зміни поведінки. Слабка відповідь звучить як «беру 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() — це нормально, а не крихкість?
Тоді, коли позиція сама є частиною перевірки. «Перший результат пошуку», «останнє повідомлення в чаті», «верхній рядок відсортованого списку» — тут порядок несе бізнес-зміст, і прив'язка до нього виправдана й навіть необхідна. Крихкість починається там, де індексом підміняють ідентичність: 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: клас тримає локатори й дії однієї сторінки, тест каже що робити, page object знає як знайти. Тоді зміна testid — це правка в одному рядку, і всі тести полагоджені разом.
Що спільного між локатором для UI і URL для API?
Обидва — технічна адреса, за якою тест звертається до системи, і обидва мають жити в одному місці, а не дублюватися по коду. Для UI це локатор у page object; для API — ендпоінт і заголовки в одному клієнті чи хелпері. Логіка ідентична: коли адреса змінюється, хочеться правити її в одному екземплярі, а не шукати двадцять копій по тестах. Тому «розмазаний по коду локатор» і «захардкоджений у кожному тесті URL» — це один і той самий борг, який вистрелить при першій же зміні.
Розробники не додають data-testid, доводиться чіплятися за випадкові класи. Це нормально?
Це не технічна проблема, а процесний борг, і крихкими локаторами його не лікують. Полювання за згенерованими класами — симптом того, що в команді немає домовленості про тест-атрибути. Правильний хід — внести стабільні зачепи в Definition of Done: фіча не вважається готовою, поки її ключові елементи не мають надійних локаторів. Це командне рішення, а не таємна зброя QA, яку той нишком припасовує до чужого коду. Сильна відповідь тут показує, що кандидат бачить різницю між «поганий код» і «поганий процес» і не намагається закрити другу дірку першою.
Три кейси, де вибір локатора вирішує, переживе тест наступний редизайн чи посиплеться на рівному місці: перепис крихкого селектора з DevTools на стабільний, пошук потрібної кнопки серед десятків однакових і винесення локаторів у page object. Скрізь — що саме крихке і як його вилікувати.
Кейс 1. Той самий елемент, п'ять локаторів різної живучості
Верстальник дав кнопку оформлення замовлення. Ось як її можна вхопити — від найкрихкішого до найстабільнішого варіанта. Розмітка:
<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 згенерований фреймворком — щобілду новий |
getByTestId('checkout-submit') | Висока | Тільки свідома зміна тест-атрибута |
getByRole('button', { name: 'Оформити замовлення' }) | Найвища | Тільки зміна призначення чи тексту кнопки |
// Погано: скопійовано з 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()несуть бізнес-зміст, і прив'язка виправдана.
Кейс 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найстабільніший: він описує призначення елемента й побіжно спирається на дерево доступності. - Розумію, що пріоритет локаторів — це політика, а не смак: чим ближче ознака до того, що бачить користувач, тим рідше вона змінюється.
Чому крихкі локатори ламаються першими
- Знаю різницю між згенерованими ознаками (
.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 }), щоб не чіплятися за позицію в списку. - Розумію, коли
nth/first/lastлегітимні: лише коли позиція сама є частиною перевірки. - Розумію, що пошук елемента й очікування — різні задачі, які не змішують через
sleep:waitForTimeout(3000)антипатерн, його замінює авточекання.
Одне джерело правди: page object
- Розумію принцип single source of truth: кожен локатор описаний рівно в одному місці й не має жити в тілі тесту — інакше зміна верстки коштує полювання по репозиторію.
- Знаю, що page object тримає локатори й дії, а тест каже лише що робити, не як знайти.
- Розумію аналогію: локатор для UI — це те саме, що URL і заголовки для API, і жити вони мають в одному сховищі.
Яке головне питання варто поставити до будь-якого локатора?
Питання
Локатор — це про пошук чи про стабільність?