Всем привет, я являюсь тимлидом команды 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'
}

Рандомизация селекторов

Когда нам нужно протестировать интерфейс, в котором есть данные в виде списка - было бы неплохо задать каждому элементу из списка уникальный селектор.

Для того чтобы решить данную проблему можно использовать один из следующих подходов:

  1. Использование последовательно-инкрементирующегося числа;

  2. Связывание селектора с данными из списка (добавление какого-либо постфикса со значением поля элемента из списка);

  3. Использование случайно сгенерированного хэша;

Предпочтительнее, конечно же, использовать третий вариант. Данный способ не использует инкрементирующееся число, которое в потенциале может дать нам коллизию, если в интерфейсе есть несколько списков одного и того же типа, а также избавляет нас от необходимости связывать селектор с данными из списка.

Для того чтобы его реализовать, можно использовать встроенный объект 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.

Вместо заключения

Если вам было интересно читать данную статью, то возможно вам понравятся и другие мои статьи. Вы можете найти их в телеге или в моём блоге.

Надеюсь смог рассказать что-то новое и интересное, хорошего дня!✨

Комментарии (10)


  1. matim_ioioi
    08.07.2025 19:39

    Спасибо за статью! Хорошее направление материала. Ранее не видел подобных статей (не искал, конечно, но и не попадались)

    Есть несколько вопросов и предложений из личного опыта

    Вопросы:

    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 соответственно) и т.д. и т.п.


    1. tokiory Автор
      08.07.2025 19:39

      Рад что вам понравилась статья!

      Касательно вопросов:

      1. Хэши удобно применять, когда приложение находится в том же пространстве, что и тесты (в одном репозитории), а также само приложение запускается в режиме разработки. Такие прогоны обычно полезны, когда разработчики сливают свои фичи и проверяют не задели ли они ничего лишнего. Сам подход избавляет нас от, порой, излишнего усложнения названия селекторов и предоставляет лаконичный подход для тестирования DEV-сборки, но для PROD-сборки, он не поможет.

      2. Если тестировать прод-версию, то да, удаление там никак не поможет. В таком случае testid оставляют, однако, в моей практике иногда делили окружения не только на DEV и PROD, но и на PRE_PROD или STAGE (кому как удобно называть). Последние идентификаторы окружения отличались от PROD только тем, что в них как раз и были данные селекторы.
        Да, придется немножко заморочиться, для того чтобы заставить те же бандлеры выдавать прод-код не только с условным NODE_ENV="production", однако, уверяю, там не так сложно и временные затраты себя окупят.

      Касательно предложений:

      1. Подход который вы описали очень похож на подход во втором пункте, с различием лишь в том, что мы добавляем именное пространство (у вас это some-page:). Я не берусь судить насколько такой подход хороший или плохой, но точно знаю несколько ситуаций, где он пригождался. Подходы к рандомизации/именованию отличаются от случая к случаю, порой такие именные пространства являются оверинжинирингом, а порой вписываются идеально.
        К слову, айдишнику из списка я бы не сильно доверял, так как если на странице есть два одинаковых списка, то их ID могут полностью совпасть.

      2. Во втором пункте, насколько я понял, вы продолжаете идею с именными пространствами, но как уже ранее говорил, порой они вписываются идеально, а порой являются усложнением


      1. matim_ioioi
        08.07.2025 19:39

        Да, придется немножко заморочиться, для того чтобы заставить те же бандлеры выдавать прод-код не только с условным NODE_ENV="production", однако, уверяю, там не так сложно и временные затраты себя окупят.

        Да нет, заморачиваться не придётся, это всё легко делается, вот только использовать разный билд для разных окружений — это плохо, но щас не будем копать эту тему, у меня и был вопрос в том, в режиме разработки запускаются тесты или проходят на стенде. Ответ я получил, спасибо)

        К слову, айдишнику из списка я бы не сильно доверял, так как если на странице есть два одинаковых списка, то их ID могут полностью совпасть.

        Не может быть на странице два одинаковых айдишника, т.к. даже если страница одна, то блоки (или контексты) будут разные :)

        Спасибо за ответ!


  1. Paczuk
    08.07.2025 19:39

    Test-Id круто в плане стабильности, только вот сам Playwright рекомендует использовать селекторы, которые базируются на том, что видит пользователь, например, это комбинация роли (кнопка) и её названия (sign in)


  1. nin-jin
    08.07.2025 19:39

    Опять вредные советы.. Почему так не надо делать хорошо описано тут: https://page.hyoo.ru/#!=9i665n_3s64xh


    1. tokiory Автор
      08.07.2025 19:39

      Прочитал вашу статью, однако, так и не понял чем ваша автоматическая генерация id удобнее стандартного прописывания testid.

      По сути (если не смотреть в сторону $mol), вы же просто используете кастомный JSX-трансформер, чтобы навесить id исходя из имён компонентов и их иерархии на вообще все элементы. Такой подход ведёт к тому, что QA, которые будут писать тесты, всегда должны использовать генерацию тестов с помощью "прокликивания" элементов, ну или смотреть на дерево компонент чтобы "по частям" собрать селектор. С точки зрения DX, такой подход - не самый удобный.

      С одной стороны, нам не приходится ручками прописывать ID, а с другой, потенциально, такой подход ведёт к огромному названию селектора, перегруженности DOM-дерева ID-шниками и невозможности зарефакторить то же название компонента без просьбы переписать тест


      1. nin-jin
        08.07.2025 19:39

        Куда вы там собрались прописывать test-id внутри компонента, который используется в 10 местах? Это так не работает. А если будете в локаторе цепляться за цепочку test-id получите те же самые "длинные" селекторы:

        $hyoo_mol.Root(0).Bench().moment().Case(1).Measurable()

        Если компонент потребовалось переименовать, значит у него изменилась семантика и test-id точно так же потребует обновления.


    1. Vitaly_js
      08.07.2025 19:39

      После того как в начале дан пример в котором варианты 1. это селектор и 1. это уникальный селектор представляется, что статья морально устарела лет этак на 5 минимум.


    1. matim_ioioi
      08.07.2025 19:39

      Чтобы такие советы не были «вредными», нужно для себя, своих конкретных целей и выработанных для себя лучших практик вычленять из статей именно то, что нужно именно Вам. А автор пишет лишь свой опыт и свои наблюдения. Если Вы так не делаете, а делаете по-другому, это не значит, что то, что написал автор — неверный подход. У каждого подхода есть плюсы и минусы

      Почему так не надо делать хорошо описано тут

      А где? Прочитал, но, что-то не увидел: а) ничего более полезного, чем в этой статье кроме, как всегда от Вас, поливания грязью всего, что не «мол», б) где написано, что так не надо делать и почему? :)


  1. Vitaly_js
    08.07.2025 19:39

    Ролевые селекторы позволяют искать элементы по их роли на странице, что делает их более стабильными и предсказуемыми, однако, этого все ещё недостаточно.

    Одной из основных проблем с использованием ролевых селекторов является их ограниченность. Они не могут быть использованы для поиска элементов по их атрибутам или содержимому, что может привести к непредсказуемым результатам.

    Также, у них есть всё та же проблема, что и у классовых селекторов - мы можем наткнуться на коллизию элементов.

    Ролевые селекторы позволяют искать элементы так как если бы их искал человек. Поэтому, если смотреть с этой стороны, то получается следующее. Второй абзац в принципе не нужен, т.к. элемент и не должен искаться по атрибуту. А вот про содержимое я не понял.

    И второе, коллизия элементов говорит о том, что с точки зрения семантики элемент спроектирован плохо, поэтому коллизия и возникает. Иными словами возникает ситуация когда ассистивная технология не может один элемент отличить от другого.

    Поэтому, основной проблемой мне видится высокий порог вхождения для использования таких селекторов, а вовсе не их ограниченность.