Зачастую обычные веб-приложения не покрывают E2E тестами, однако, когда разговор заходит об административных панелях, формах биллинга и разнообразных конструкторах, то данная потребность быстро возникает. В этой статье мы рассмотрим, как правильно организовать селекторы для тестирования веб-приложений.
В рамках данной статьи мы будем использовать фреймворк Playwright.
Вы можете использовать Testcafe, Puppeteer, Cypress, WebdriverIO или любую другую технологию, которая позволяет писать E2E-тесты.
Проблема нестабильных селекторов
Одним из огромных минусов E2E-тестирования является скорость выполнения данных тестов.
Даже если мы будем кэшировать и/или мокать запросы, то сам процесс запуска и тестирования в Headless-браузере может быть очень долгим.
Если мы добавим к данной проблеме еще и нестабильные селекторы, то мы можем столкнуться с проблемой, когда тесты будут падать из-за изменений в интерфейсе, а весь прогон будет огромное количество времени.
Атрибуты для тестирования
Атрибут 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
:
const element = driver.findElement(By.cssSelector(`[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 LoginSelectors {
EmailInput = 'login-email-input',
PasswordInput = 'login-password-input',
LoginButton = 'login-button'
}
export enum RegistrationSelectors {
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
]
});
Делегирование создания селекторов
В случае, когда в проекте нет ресурсов для создания testid
-селекторов, имеет смысл делегировать создание селекторов команде QA.
Обычно для такого подхода используется следующий флоу:
В случае, когда селекторы делегированы, имеет смысл указывать их в формате отличном от Javascript-объектов, для того чтобы можно было переиспользовать их в проектах, где для автотестов используется Python/Java.
В таких случаях можно использовать формат JSON/YAML/TOML.