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

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

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

    Зміст

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

    Так

    Ні

    Так

    Ні

    Так

    Ні

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

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

    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();

    Індекс (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: від класики до компонентів»; тут важливо запам'ятати сам інваріант: локатор не має жити в тілі тесту.

    змінився 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) показує вже інший рядок (до того ж індекс тут відлічується з нуля). Шукай за вмістом, індекс — лише коли позиція сама є перевіркою.
    • Виглядає як чисте очікування, а насправді це замаскований флак. 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-* як механізм, на якому стоять тест-ідентифікатори.