vyvchy
    Теми розділу

    06 · Автоматизація: стратегія

    Локатори: стратегія стабільних селекторів

    Зміст

    (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 не в тому, що він «поганий сам по собі», а в тому, що він майже завжди чіпляється за структуру, а не за зміст.

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Треба знайти елемент

    Є видима роль
    і доступне ім'я?

    getByRole 'button', name: 'Купити'

    Є стабільний
    текст або мітка?

    getByText / getByLabel

    Розробники додали
    data-testid?

    getByTestId

    CSS/XPath — крайній засіб,
    попроси додати testid

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Треба знайти елемент

    Є видима роль
    і доступне ім'я?

    getByRole 'button', name: 'Купити'

    Є стабільний
    текст або мітка?

    getByText / getByLabel

    Розробники додали
    data-testid?

    getByTestId

    CSS/XPath — крайній засіб,
    попроси додати testid

    Чому згенеровані й позиційні локатори ламаються першими

    Два класи локаторів гарантовано підведуть — і саме їх найчастіше пишуть новачки, бо їх найлегше «намацати» через праву кнопку → 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: від класики до компонентів»; тут важливо запам'ятати сам інваріант: локатор не має жити в тілі тесту.

    змінився testid

    одна правка

    одна правка

    одна правка

    test: checkout

    CheckoutPage
    локатори + дії

    test: guest checkout

    test: empty cart

    data-testid='checkout-submit'

    змінився testid

    одна правка

    одна правка

    одна правка

    test: checkout

    CheckoutPage
    локатори + дії

    test: guest checkout

    test: empty cart

    data-testid='checkout-submit'

    Той самий принцип працює й поза 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 спрощує підтримку, бо захоплює селектори елементів в одному місці.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.