javascript

Организация селекторов для тестирования

  • среда, 9 июля 2025 г. в 00:00:04
https://habr.com/ru/articles/925986/

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

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

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

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