Всем привет, я являюсь тимлидом команды Frontend-разработки в компании Firecode.
В решениях, которые мы разрабатываем, часто используются E2E-тесты, поэтому я решил поделиться одной из самых используемых практик внутри наших проектов - дата-селекторы.
Зачастую обычные веб-приложения не покрывают E2E тестами, однако, когда разговор заходит об административных панелях, формах биллинга и разнообразных конструкторах, то данная потребность быстро возникает. В этой статье мы рассмотрим, как правильно организовать селекторы для тестирования веб-приложений.
В рамках данной статьи мы будем использовать фреймворк Playwright.
Вы можете использовать Testcafe, Puppeteer, Cypress, WebdriverIO или любую другую технологию, которая позволяет писать E2E-тесты.
Проблема нестабильных селекторов
Одним из огромных минусов E2E-тестирования является скорость выполнения данных тестов.
Даже если мы будем кэшировать и/или мокать запросы, то сам процесс запуска и тестирования в Headless-браузере может быть очень долгим.
Если мы добавим к данной проблеме еще и нестабильные селекторы, то мы можем столкнуться с проблемой, когда тесты будут падать из-за изменений в интерфейсе, а весь прогон будет огромное количество времени.
Примеры нестабильных селекторов:
Классовые селекторы:
await page.locator('.button-primary');
У данного типа селектора есть очевидные минусы:
Возможны коллизии элементов с одним и тем же классом;
Возможны изменения классов в зависимости от состояния программы;
Возможны изменения названий классов при рефакторинге кода;
Более того, в интерфейсах где используются библиотеки, такие как Tailwind, классы не несут смысловой нагрузки и могут быть изменены без предупреждения.
Селектор с вложенностью:
await page.locator('div > div:nth-child(2) > span');
Обычно такие селекторы любит составлять Playwright при генерации тестов с помощью команды npx playwright codegen
.
Из очевидных минусов такого подхода может быть:
Сложность поддержки и обновления селекторов в случае изменения структуры страницы.
Нет гарантии уникальности селектора, что может привести к непредсказуемым результатам.
Сложность чтения и понимания селекторов, особенно в случае вложенных структур.
Ролевые селекторы
page.getByRole('heading', { name: 'Sign up' });
Ролевые селекторы позволяют искать элементы по их роли на странице, что делает их более стабильными и предсказуемыми, однако, этого все ещё недостаточно.
Одной из основных проблем с использованием ролевых селекторов является их ограниченность. Они не могут быть использованы для поиска элементов по их атрибутам или содержимому, что может привести к непредсказуемым результатам.
Также, у них есть всё та же проблема, что и у классовых селекторов - мы можем наткнуться на коллизию элементов.
Атрибуты для тестирования
Атрибут data-testid
(или аналогичный, например, data-test-id
, data-test
) применяется для явной маркировки элементов, которые участвуют в автоматизированных тестах. Его назначение — обеспечить стабильную и независимую идентификацию элементов интерфейса в рамках тест-кейсов.
Наименование атрибута может варьироваться в зависимости от выбранного фреймворка (Playwright, Testing Library, Cypress и др.) и внутренних соглашений команды.
Если необходимо протестировать поле ввода электронной почты, элемент может быть размечен следующим образом:
<input type="email" data-testid="email-input" />
Такой подход обладает рядом преимуществ:
Независимость от DOM-структуры и CSS-классов — изменения в стилях или верстке не влияют на тесты;
Прозрачность и стабильность — значения
data-testid
фиксированы и не изменяются в ходе разработки;Упрощённая поддержка — разработчики и тестировщики получают однозначный способ обращения к элементам.
Среди минусов такого подхода можно выделить самый очевидный – нам придется часто дергать команду разработки для добавления/изменения атрибутов, но это всяко лучше, чем иметь нестабильные селекторы.
Как использовать данные селекторы?
Представим, что у нас есть input
, о котором мы упомянули выше:
<input type="email" data-testid="email-input" />
Для того чтобы получить элемент по данному селектору, мы можем использовать специальную функцию из Playwright:
import { test, expect } from '@playwright/test';
const EMAIL_INPUT = 'email-input';
test('should fill email input', async ({ page }) => {
await page.goto('http://localhost:3000');
const emailInput = page.getByTestId(EMAIL_INPUT);
await emailInput.fill('test@example.com');
});
Если мы хотим использовать другое наименование для данного атрибута, то мы можем редактировать название данного атрибута в конфигурации Playwright:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-custom-test-id'
}
});
Если у вашего фреймворка нет поддержки нахождения элементов по атрибуту data-testid
с помощью специального метода, то мы можем использовать синтаксис CSS для нахождения элементов по атрибуту data-testid
:
// Selenium
const element = driver.findElement(
By.cssSelector(`[data-testid='${TEST_SELECTOR}']`)
);
// TestCafe
const element = Selector(`[data-testid='${TEST_SELECTOR}']`);
// И так далее. В целом, почти во всех фреймворках
// есть возможность захватить элемент по атрибуту через синтаксис
// с квадратными скобками
Организация значений для селекторов
В большинстве проектов хранение селекторов реализуется через файл tests/constants/selectors.ts
, в котором описываются все используемые идентификаторы.
Обычно для таких целей можно создать словарь с testid
, который будет содержать все возможные testid
приложения. Если вы используете Typescript, то можно использовать перечисления, для того чтобы случайно не продублировать значения testid
:
export enum TestIds {
SendButton = 'send-button',
CancelButton = 'cancel-button',
SubmitButton = 'submit-button'
}
Такой подход позволяет нам убедиться в уникальности каждого из селекторов, однако, со временем данное перечисление может разрастись на тысячи и тысячи значений. Чтобы такого не случилось можно разделить словарь на части. Частями могут выступать:
Целые сервисы;
Страницы;
Тест-кейсы;
Самым практичным способом деления словаря является деление на страницы:
export const loginPageSelectors = {
emailInput: 'login-email-input',
passwordInput: 'login-password-input',
loginButton: 'login-button'
};
export const registrationPageSelectors = {
emailInput: 'registration-email-input',
passwordInput: 'registration-password-input',
registrationButton: 'registration-button'
};
Обратите внимание, что при подходе с хэшмапами, вам придется самим следить за уникальностью значений.
Можно использовать все те же перечисления, но для каждого раздела (в данном случае страницы), для того чтобы избежать коллизий значений внутри одного раздела:
export enum LoginSelector {
EmailInput = 'login-email-input',
PasswordInput = 'login-password-input',
LoginButton = 'login-button'
}
export enum RegistrationSelector {
EmailInput = 'registration-email-input',
PasswordInput = 'registration-password-input',
RegistrationButton = 'registration-button'
}
Рандомизация селекторов
Когда нам нужно протестировать интерфейс, в котором есть данные в виде списка - было бы неплохо задать каждому элементу из списка уникальный селектор.
Для того чтобы решить данную проблему можно использовать один из следующих подходов:
Использование последовательно-инкрементирующегося числа;
Связывание селектора с данными из списка (добавление какого-либо постфикса со значением поля элемента из списка);
Использование случайно сгенерированного хэша;
Предпочтительнее, конечно же, использовать третий вариант. Данный способ не использует инкрементирующееся число, которое в потенциале может дать нам коллизию, если в интерфейсе есть несколько списков одного и того же типа, а также избавляет нас от необходимости связывать селектор с данными из списка.
Для того чтобы его реализовать, можно использовать встроенный объект crypto
:
import { randomUUID } from 'crypto';
export const randomizeSelector = (selector: string) =>
`${selector}:${randomUUID()}`;
///// В файле с селекторами для формы регистрации: /////
// Импортируем массив городов из файла cities.ts
import { CITIES } from '@/data/cities';
// Генерируем селекторы для каждого из городов
export const cities = Array.from({ length: CITIES.length },
(_, i) => randomizeSelector('city'));
Очевидным минусом такого подхода является трудночитаемость селектора.
Скрытие атрибутов
Если мы активно начнем указывать data-testid
по всему проекту, то мы вряд ли захотим чтобы кто-то кроме команды разработки и QA знал какие селекторы мы используем и как проводим тестирование.
Для того чтобы скрыть атрибуты, мы можем немножко изменить процесс сборки:
Для Vue есть пакет @castlenine/vite-remove-attribute:
export default defineConfig({
plugins: [
// Плагин Vue должен быть расположен перед плагином удаления атрибутов
vue(),
process.env.NODE_ENV == 'production'
? removeAttribute({
extensions: ['vue'],
attributes: ['data-testid']
})
: null
]
});
Для Svelte есть всё тот же пакет @castlenine/vite-remove-attribute:
export default defineConfig({
plugins: [
process.env.NODE_ENV == 'production'
? removeAttribute({
extensions: ['svelte'],
attributes: ['data-testid']
})
: null,
// Плагин SvelteKit должен быть расположен после плагина удаления атрибутов
sveltekit()
]
});
Для React есть пакет rollup-plugin-jsx-remove-attributes:
export default defineConfig({
build: { sourcemap: true },
plugins: [
react(),
removeTestIdAttribute({
usage: 'vite'
})
]
});
Делегирование создания селекторов
В случае, когда в проекте нет ресурсов для создания testid
-селекторов, имеет смысл делегировать создание селекторов команде QA.
Обычно для такого подхода используется следующий флоу:

В случае, когда селекторы делегированы, имеет смысл указывать их в формате отличном от Javascript-объектов, для того чтобы можно было переиспользовать их в проектах, где для автотестов используется Python/Java.
В таких случаях можно использовать формат JSON/YAML/TOML.
Вместо заключения
Если вам было интересно читать данную статью, то возможно вам понравятся и другие мои статьи. Вы можете найти их в телеге или в моём блоге.
Надеюсь смог рассказать что-то новое и интересное, хорошего дня!✨
Комментарии (8)
Paczuk
08.07.2025 19:39Test-Id круто в плане стабильности, только вот сам Playwright рекомендует использовать селекторы, которые базируются на том, что видит пользователь, например, это комбинация роли (кнопка) и её названия (sign in)
nin-jin
08.07.2025 19:39Опять вредные советы.. Почему так не надо делать хорошо описано тут: https://page.hyoo.ru/#!=9i665n_3s64xh
tokiory Автор
08.07.2025 19:39Прочитал вашу статью, однако, так и не понял чем ваша автоматическая генерация
id
удобнее стандартного прописыванияtestid
.По сути (если не смотреть в сторону $mol), вы же просто используете кастомный JSX-трансформер, чтобы навесить
id
исходя из имён компонентов и их иерархии на вообще все элементы. Такой подход ведёт к тому, что QA, которые будут писать тесты, всегда должны использовать генерацию тестов с помощью "прокликивания" элементов, ну или смотреть на дерево компонент чтобы "по частям" собрать селектор. С точки зрения DX, такой подход - не самый удобный.С одной стороны, нам не приходится ручками прописывать ID, а с другой, потенциально, такой подход ведёт к огромному названию селектора, перегруженности DOM-дерева ID-шниками и невозможности зарефакторить то же название компонента без просьбы переписать тест
nin-jin
08.07.2025 19:39Куда вы там собрались прописывать test-id внутри компонента, который используется в 10 местах? Это так не работает. А если будете в локаторе цепляться за цепочку test-id получите те же самые "длинные" селекторы:
$hyoo_mol.Root(0).Bench().moment().Case(1).Measurable()
Если компонент потребовалось переименовать, значит у него изменилась семантика и test-id точно так же потребует обновления.
Vitaly_js
08.07.2025 19:39После того как в начале дан пример в котором варианты 1. это селектор и 1. это уникальный селектор представляется, что статья морально устарела лет этак на 5 минимум.
Vitaly_js
08.07.2025 19:39Ролевые селекторы позволяют искать элементы по их роли на странице, что делает их более стабильными и предсказуемыми, однако, этого все ещё недостаточно.
Одной из основных проблем с использованием ролевых селекторов является их ограниченность. Они не могут быть использованы для поиска элементов по их атрибутам или содержимому, что может привести к непредсказуемым результатам.
Также, у них есть всё та же проблема, что и у классовых селекторов - мы можем наткнуться на коллизию элементов.
Ролевые селекторы позволяют искать элементы так как если бы их искал человек. Поэтому, если смотреть с этой стороны, то получается следующее. Второй абзац в принципе не нужен, т.к. элемент и не должен искаться по атрибуту. А вот про содержимое я не понял.
И второе, коллизия элементов говорит о том, что с точки зрения семантики элемент спроектирован плохо, поэтому коллизия и возникает. Иными словами возникает ситуация когда ассистивная технология не может один элемент отличить от другого.Поэтому, основной проблемой мне видится высокий порог вхождения для использования таких селекторов, а вовсе не их ограниченность.
matim_ioioi
Спасибо за статью! Хорошее направление материала. Ранее не видел подобных статей (не искал, конечно, но и не попадались)
Есть несколько вопросов и предложений из личного опыта
Вопросы:
1. При рандомизации селекторов Вашим предложенным «лучшим» способом через хэши не понятно, как команда автотестирования будет внедрять их в свои тесты?
2. Про скрытие атрибутов — на стенд выкладывается прод сборка приложения. Соответственно, такой подход с «удалением» атрибутов из бандла прод сборки не поможет (я про тот случай, когда e2e тесты запускаются не напрямую «в приложении» (например, на этапе ci), а отдельными инструментами. P.S. не претендую на инстанцию истины, но у меня был опыт, когда e2e и нагрузочные (они тут не при чём, но просто к слову) тесты гоняли непосредственно на стенде с помощью гатлинга). Могли бы Вы раскрыть тему в таком кейсе? Или же Вы только про тот кейс, когда тесты запускаются «в приложении» (опять же, например, на этапе ci)?
Предложения:
1. Вместо построения айдишников с помощью хэшэй мы использовали просто «префиксы» для них. Например, от названия страницы, конкретного блока или какого-то определённого юзкейса и добавляли к нему индекс итема (примерно так: «some-page:region-list[0]»)
2. Для упрощения работы с автотестировщиками можно сделать некий «контракт» по названиям тестовых айдишников. Например, будем называть так: <страница>:<тип блока>:<наименование блока/контекст>[индекс, если это элемент списка], то есть для страницы каталога, фильтра по регионам будем использовать такой вид: «catalog:filter:regions», а для элементов списка региона «catalog:filter:region[0/1/2/3/…]». Где «тип блока» — это заданный перечень типов, например: просто блок (будь какой-то див с инфой) — block, кнопка — button, фильтр — filter (если нужно разбиение по типу фильтра, например, инпут или селект, можно сделать input или select соответственно) и т.д. и т.п.
tokiory Автор
Рад что вам понравилась статья!
Касательно вопросов:
Хэши удобно применять, когда приложение находится в том же пространстве, что и тесты (в одном репозитории), а также само приложение запускается в режиме разработки. Такие прогоны обычно полезны, когда разработчики сливают свои фичи и проверяют не задели ли они ничего лишнего. Сам подход избавляет нас от, порой, излишнего усложнения названия селекторов и предоставляет лаконичный подход для тестирования DEV-сборки, но для PROD-сборки, он не поможет.
Если тестировать прод-версию, то да, удаление там никак не поможет. В таком случае
testid
оставляют, однако, в моей практике иногда делили окружения не только на DEV и PROD, но и на PRE_PROD или STAGE (кому как удобно называть). Последние идентификаторы окружения отличались от PROD только тем, что в них как раз и были данные селекторы.Да, придется немножко заморочиться, для того чтобы заставить те же бандлеры выдавать прод-код не только с условным NODE_ENV="production", однако, уверяю, там не так сложно и временные затраты себя окупят.
Касательно предложений:
Подход который вы описали очень похож на подход во втором пункте, с различием лишь в том, что мы добавляем именное пространство (у вас это
some-page:
). Я не берусь судить насколько такой подход хороший или плохой, но точно знаю несколько ситуаций, где он пригождался. Подходы к рандомизации/именованию отличаются от случая к случаю, порой такие именные пространства являются оверинжинирингом, а порой вписываются идеально.К слову, айдишнику из списка я бы не сильно доверял, так как если на странице есть два одинаковых списка, то их ID могут полностью совпасть.
Во втором пункте, насколько я понял, вы продолжаете идею с именными пространствами, но как уже ранее говорил, порой они вписываются идеально, а порой являются усложнением