QA Interview Hub · База знань · Тести

Playwright

Локатори

Локатори і Locator vs ElementHandle

Рекомендовані вбудовані локатори: getByRole, getByLabel, getByPlaceholder, getByText, getByAltText, getByTitle і getByTestId. Пріоритет — getByRole з name, бо він відображає, як сторінку бачить користувач і технології доступності. CSS і XPath через page.locator() — запасний варіант.

Locator лінивий: під час створення він нічого не шукає, пошук відбувається щоразу в момент дії чи перевірки. Тому він переживає перерендер і не дає stale element помилок. Перед дією Playwright автоматично чекає, поки елемент буде attached, visible, stable, enabled і прийматиме події.

Locator працює в strict mode: якщо селектор знаходить кілька елементів, дія кидає помилку, і треба уточнити через filter, first, nth або ланцюжок. ElementHandle — посилання на конкретний DOM-вузол, схоплений один раз, без auto-waiting і strict mode, тому він discouraged. Він потрібен рідко, наприклад для передачі вузла в evaluate.

getByRole глибше: опції і точність

getByRole шукає за ARIA-роллю та доступним ім'ям. Важливо знати, як працює name: за замовчуванням це пошук підрядка без урахування регістру. Тобто { name: 'Зберегти' } знайде і кнопку «Зберегти чернетку». Для точного збігу є exact: true, а для гнучкого — регулярний вираз.

page.getByRole('button', { name: 'Зберегти', exact: true });
page.getByRole('link', { name: /^Замовлення №\d+$/ });
page.getByRole('heading', { name: 'Кошик', level: 1 });
page.getByRole('checkbox', { name: 'Погоджуюсь', checked: true });
page.getByRole('button', { name: 'Меню', expanded: false });
page.getByRole('tab', { selected: true });

Додаткові опції фільтрують за станом: checked, pressed, expanded, selected, disabled, level для заголовків. За замовчуванням приховані елементи не враховуються; includeHidden: true це змінює.

Доступне ім'я береться з тексту елемента, aria-label, aria-labelledby або пов'язаного label. Якщо getByRole не знаходить поле, це часто справжній баг доступності: інпут без label не прочитає і скрінрідер. Я про такі випадки повідомляю розробникам, а не обходжу через CSS.

Для перевірки структури цілого блоку з версії 1.49 є toMatchAriaSnapshot: він порівнює дерево доступності з YAML-шаблоном. Це зручно для меню чи форм, де важливий склад і порядок елементів.

На співбесіді: У getByRole ім'я за замовчуванням шукається як підрядок без регістру, тому для однакових кнопок я використовую exact: true або regex. Також застосовую опції стану, як checked чи expanded. Якщо роль не знаходиться, це сигнал про проблему доступності.

Ланцюжки, filter, has, and, or

Strict mode змушує уточнювати локатори, і для цього є кілька інструментів.

Ланцюжок звужує область пошуку: page.getByTestId('cart').getByRole('button', { name: 'Видалити' }). Внутрішній локатор шукається лише всередині зовнішнього.

filter залишає елементи, що відповідають умові:

const row = page.getByRole('row')
  .filter({ hasText: 'INV-1042' })
  .filter({ has: page.getByRole('cell', { name: 'Оплачено' }) });
await row.getByRole('button', { name: 'Повернення' }).click();

const activeItems = page.getByRole('listitem').filter({ hasNot: page.getByText('Архів') });
const visibleToasts = page.getByRole('alert').filter({ visible: true });

Різниця важлива: ланцюжок шукає дочірній елемент, а filter({ has }) повертає сам зовнішній елемент, у якого є такий нащадок. Тобто «знайди рядок, де є клітинка Оплачено», а не саму клітинку. Є також hasText, hasNotText, hasNot і visible.

and перетинає два локатори для одного елемента: page.getByRole('button').and(page.getByTitle('Підписатися')). or дає альтернативу: «або діалог згоди, або кнопка Продовжити» — корисно, коли застосунок може показати один з двох станів.

first(), last(), nth() — останній варіант. Вони прив'язуються до порядку в DOM, тому ламаються від зміни сортування. Якщо пишу nth, то лише коли порядок — частина вимоги.

На співбесіді: Для таблиць я шукаю рядок через filter з hasText або has, а потім усередині рядка — потрібну кнопку. nth використовую лише коли порядок важливий за вимогами. or допомагає, коли застосунок легітимно може показати один з двох станів.

Test id, списки і пастка all()

getByTestId за замовчуванням шукає атрибут data-testid. Якщо в проєкті прийнято інший, наприклад data-qa, його задають у конфізі: use: { testIdAttribute: 'data-qa' }. Test id я використовую, коли немає стабільної ролі й тексту: іконки без підпису, динамічні контейнери, мультимовні тексти.

Для перевірки списку краще асерти по всьому локатору, а не цикли:

const items = page.getByRole('listitem');
await expect(items).toHaveCount(3);
await expect(items).toHaveText(['Хліб', 'Молоко', 'Сир']);

toHaveText з масивом перевіряє і кількість, і порядок, і ретраїть до таймауту.

Пастка — locator.all(). Він повертає масив локаторів для елементів, які є в DOM зараз, і не чекає. Якщо список ще вантажиться, отримаю порожній масив, і цикл просто нічого не перевірить — тест зелений, хоча нічого не протестовано. Тому перед all() спочатку чекаю стан, наприклад через toHaveCount.

await expect(items).toHaveCount(3);
for (const item of await items.all()) {
  await expect(item.getByRole('img')).toBeVisible();
}

Так само count(), textContent() і allTextContents() повертають значення одномоментно, без ретраїв. Ними можна користуватися для логіки, але не для перевірок.

На співбесіді: Для списків я використовую toHaveCount і toHaveText з масивом, бо вони ретраяться. all() не чекає елементів, тому перед ним завжди ставлю асерт на кількість, інакше цикл може пройти по порожньому масиву і дати хибно зелений тест.