Как стать автором
Поиск
Написать публикацию
Обновить

Записки одного QA. Вспомогательная часть автотестов: советы и практики (Playwright + Typescript)

Уровень сложностиПростой
Время на прочтение46 мин
Количество просмотров1.3K

Глава 1. Как все начиналось. Немного истории и воды о моем личном опыте.

Всем привет!
Меня зовут Майнура.

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

Мультяшный дракула говорит! Не сквернословит!
Мультяшный дракула говорит! Не сквернословит!

Мысль о переходе на Playwright возникла в тот момент, когда стало очевидно, что Codeception не справляется с рядом задач, особенно в части автоматизации сценариев с WebView.

А минусы были весомые, используя Codeception:

  • ❌ До PHP 8 всё распределение потоков в Codeception было полностью ручным. Представьте себе этот гемор: чтобы запустить автотесты параллельно, нужно либо обновлять проект до 8-й версии PHP, либо всё распределять вручную. Честно говоря, идея ручного распределения меня совсем не вдохновляла, поэтому я оставила свой первый проект с API-автотестами на последовательном запуске (Прости меня за это мой первый проект с автотестами) .

  • ❌ Самое главное! нет возможности использования разных браузеров для UI тестов для регресса кейсов на WEBVIEW страницах (а это был ключевой минус). Работа с браузерами - ограничена (обычно Chrome через WebDriver)

  • ❌ Скорость выполнения тестов медленнее, особенно при последовательном запуске

  • ❌ Нет генерации кода

  • ❌ Меньше функций для визуального сравнения

  • ❌ Дебаггинг тестов мог бы быть полегче. В Codeception не хватает удобных инструментов для отладки: нельзя в реальном времени видеть, какая строка выполняется, какой запрос уходит и какие ошибки появляются в консоли. Всё это сильно усложняет поиск проблем.

  • ❌ Не было возможности визуального сравнения

  • ❌ В Codeception доступно меньше видов ассертов. В основном используются проверки вроде see, dontSee и кастомные методы. Отсутствуют встроенные проверки наподобие особенно для работы с элементами, включая проверки видимости, текста, состояния и атрибутов, а также нет поддержки soft-ассертов

  • ❌ Поиск элементов выполняется ТОЛЬКО через селекторы: data-test-id, id, class и т д. Использование классов в качестве селектора (class) считается плохой практикой, так как они могут быть изменены или хешированы( class="yellow__1a2b3" может измениться на class="yellow__97dhfgg")

  • ❌ Мало статей с best practices

Вот список плюсов Playwright которые решают минусы Codeception :

  • Автоматическое распределение потоков. Тесты запускаются параллельно без сложных настроек.

  • Поддержка кроссбраузерности. Playwright работает с Chromium, Firefox, WebKit и даже с мобильными эмуляциями.

  • Быстрый прогон тестов. Время прогона тестов на Playwright намного быстрее чем на Codeception и локально и в CI/CD (см. Таблица 1)

  • Лёгкость написания UI-автотестов с помощью Codegen — можно генерировать тесты, записывая действия прямо в браузере.

  • Удобный дебаггинг автотестов локально с пошаговым выполнением теста с возможностью наблюдать, что происходит на каждом этапе автотеста. В Playwright поддерживаются гибкие команды запуска: можно запускать один тест, группу тестов или повторно прогонять только упавшие сценарии. Благодаря наличию Trace Viewer можно просматривать скриншоты и DOM-состояние на каждом шаге теста, анализировать сетевые запросы и ответы, а также быстро находить точку падения и причину ошибки, что делает процесс отладки быстрым и наглядным.

  • Визуальное сравнение. В Playwright есть функция для визуального сравнения, реализованная через метод expect().toHaveScreenshot(), который позволяет быстро сделать скриншот элемента или страницы и сравнить его с ожидаемым результатом прямо в тесте (скриншотное сравнение).

  • Много видов ассертов и soft-ассерты, особенно для работы с элементами, включая проверки видимости, текста, состояния и атрибутов (await elementHandle.isChecked(), await elementHandle.isEditable(), await elementHandle.isEnabled()). Также поддержка soft-ассертов (мягкие проверки), что позволяет продолжать выполнение теста даже при падении отдельных проверок, делая тестирование более гибким и удобным.

  • Гибкие способы поиска элементов. В Playwright проверки писать легко и удобно благодаря поддержке различных способов поиска элементов. Можно обращаться к ним через разметку (heading, label, text, paragraph и т. п.), использовать селекторы (data-test-id, id и т д), проверять визуальные характеристики (цвет текста и цвет фона), выполнять сравнение через скриншоты, а также применять функции описания локаторов (locator.getByRole(), locator.getByText(), locator.getByTestId()), что делает автотесты максимально читаемыми и простыми даже для сложных интерфейсов.

  • Обширная документация и статьи. Кроме официальной документации существует множество статей, гайдов и примеров. Кроме того, с выходом каждой новой версии возможности фреймворка значительно расширяются, добавляются новые методы локаторов, улучшения для API-тестирования, функции для работы с мобильной автоматизацией и новые возможности для дебага.

Таблица 1. Время выполнения автотестов Playwright vs Codeception
Таблица 1. Время выполнения автотестов Playwright vs Codeception

Внимание! В этой статье будут приведены примеры, не относящиеся к WebView — это общие примеры с best practices, приведённые для иллюстрации подходов, а не конкретной реализации.

Внимание! Также дополнительные пояснения и примеры будут в скрытом тексе.


Глава 2. Вспомогательная часть для автотестов. 🚀 Мои best practices

В данной главе описана вспомогательная структура, служащая фундаментом для создания надёжных и легко поддерживаемых автотестов. А именно: PageObject-базовые классы, компоненты, локаторы, константы, helpers, step pattern, config, snapshots.

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

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

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

И такс, начнем 🚀👇

🗂 Структура

C чего начнем? Конешно же, с построения структуры тестов в репозитории.

И так в тестах нужна такая структура, котороя легко масштабируется, понятна для любого QA и разработчика, и красивая ( без красоты жить нельзя!)

Сперва необходимо определить, какие виды тестов будут реализованы для проекта (репозитория). Важно уточнить:

  • нужны ли автотесты только для эндпоинтов микросервиса;

  • требуются ли e2e-автотесты для UI-проекта;

  • либо должна быть создана структура тестов, которая проверяет и API, и UI в одном репозитории (при этом API-тесты должны покрывать только те эндпоинты, которые вызываются из UI)

  • где будут установлены devDependencies в корневом package.json , или в директории с тестами.

Побольше о том какой package.json использовать для автотестов

Если тестовые зависимости устанавливаются в корневой package.json:

  • то это обеспечивает единый node_modules

  • отсутствие дублирования зависимостей

  • упрощённую настройку сборки и CI/CD

  • но при этом тестовые зависимости смешиваются с продакшн-зависимостями

  • может увеличиться размер продакшн-образа (если не разделены dependencies и devDependencies)

  • не минимизируется их влияние на основной проект и снижается читаемость package.json)

"Я бы не смешивала тестовые зависимости (devDependencies) с продакшн-зависимостями в корневом package.json и вынесла бы их в отдельную директорию с собственным package.json, чтобы изолировать тесты, упростить их поддержку и сохранить чистоту основного проекта"

Тестовый проект будет состоять из трёх основных разделов: common, snapshots и tests и конфигурационного файла.

Структура  директорий  и файлов для автотестов
Структура директорий и файлов для автотестов

- common – общие ресурсы и вспомогательные модули (компоненты, константы, сервисы, локаторы, страницы, конфигурации и шаги).

- snapshots – снимки состояния (например, для визуальных тестов или сравнения результатов).

- tests – сами тесты, сгруппированные по типам (smoke, e2e, api)

- playwright.config.ts - это конфигурационный файл для настройки запуска автотестов и общие настройки Playwright

📁 Правила наименования и группировки файлов 📌 (обязательно прочитать!)

Далее, для ясности, необходимо определить правила именования файлов и директорий.

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

  • Файлы, cодержащие локаторы: используем kebab-case + оканчание locators. Пример: filter-locators.ts

  • Файлы, cодержащие хелперы (services): используем kebab-case + оканчание на helper. Пример: date-format-helper.ts

  • Файлы c константами: используют kebab-case и оканчиваются на constants. Пример: profile-constants.ts

  • Файлы компонентов и страниц: используют PascalCase и оканчиваются на Component или Page. Пример: UserProfilePage.ts, FilterComponent.ts

  • Файлы c самими тестами: используют PascalCase с расширением .spec.ts . Пример: ProfileSettings.spec.ts 

  • Все остальные файлы: используют kebab-case. Пример: api-routes.ts

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

Когда в директориях common(support) или tests необходимо разделить содержимое на несколько связанных файлов, их следует объединять в одну папку. Это повышает читаемость и упрощает навигацию.

  1. Если для одной фичи есть несколько компонентов или страниц (например, Authorization) → объединяйте их в отдельную подпапку. Пример: pages/auth/LoginPage.ts, pages/auth/RegisterPage.ts

  2. Если для одной фичи хватает только одного файла (например, ProfilePage.ts, EditProfileTest.spec.ts) → не создавайте для неё отдельную подпапку.


👉 Далее разберем что к чему в директории common (support) в структуре тестового проекта на Playwright.

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

Директория common(или же распространенное среди тестировщиков название -> support) собирает в себе всё необходимое для тестов, делая их структуру более понятной и упорядоченной. Здесь хранятся переиспользуемые методы, константы, функции и локаторы, которые упрощают написание и поддержку тестов.

Папка common(support) состоит из 8 директорий и конфигурационного файла Playwright:

  1. base - используется для хранения общих базовых классов (Common, BasePage, BaseComponent и других), от которых наследуются страницы и компоненты.

  2. constants – глобальные константы и значения, используемые во всём проекте

  3. helpers – вспомогательные сервисы и утилиты (общие функции для тестов: форматирование дат, работа с профилем пользователя, преобразование данных и пр.)

  4. locators – локаторы элементов для тестов

  5. apiRoutes– эндпоинты для API.

  6. components – общие UI-компоненты, используемые в тестах.

  7. pages – объектная модель страниц (Page Object Model).

  8. steps – шаги для e2e и API тестов.

  9. config – конфигурационный файл Playwright.

📌 Base (базовые классы)

Для работы с PageObject или ComponentObject потребуется определить базовые классы (или один базовый класс), в котором будет реализован конструктор, указывающий, что работа ведётся именно с playwright.Page, а также заданы общие методы и функции, которые можно использовать по всему проекту или переопределять при необходимости для изменения настроек.

Эти базовые классы будут лежать в директории /common/base/

Название классов будет следующими: BasePage или BaseComponent или Base.

Пример структуры

⚠️ Важно! в моих примерах будет один базовый класс Base, который хранит this.page в конструкторе и все классы ComponentObject / PageObjects унаследованы от Base.

Класс Base - хранит общую логику работы с проверками(еlementVisibility или compareScrenshots) , ожиданиями, переопределение атрибутов селекторов (например, data-testid / ранее data-test-id)

Пример Base класса
Путь common/base/Base.ts

import { expect, Locator, Page, selectors } from '@playwright/test';

export class Base {
  readonly page: Page;

  selectors.setTestIdAttribute('data-test-id');

  constructor(page: Page) {
    this.page = page;;
  }

  /**
   * Проверка, что элемент виден
   */
  async elementVisibility(target: Locator) {
    await expect(target).toBeVisible();
  }

  /**
   * Сравнение фактического и ожидаемого скриншота
   */
  async areScreenshotsMatch(
    target: Locator | Page,
    name?: string,
    increasedDiff = 1,
    options: Record<string, unknown> = {}
  ) {
    const diffPixels = 100 * increasedDiff;
    await expect.soft(target).toHaveScreenshot(name ?? '', {
      maxDiffPixels: diffPixels,
      ...options,
    });
  }
}

📌 Constants

Зачем выносить константы? - Вынос констант в отдельные файлы позволяет:

  • Избегать захламления проекта и дублирования данных.

  • Поддерживать логичную структуру и быстро находить нужные значения.

  • Упрощать рефакторинг и поддержку проекта.

Примеры констант
  • profile-constants.tsсодержит все константы, связанные с профилем.

Пример константы
Пример константы
  • common-constants.tsсодержит общие константы, которые могут использоваться в любом месте тестового проекта и не привязаны к конкретной странице или компоненту.

Пример констант
Пример констант

Все статические значения, используемые в тестовом проекте, должны располагаться в директории: /common/constants.

Директория constants состоит из файлов, название которых чётко описывает общую идею и содержание констант в файле. Имя файла должно быть в формате kebab-case и заканчиваться на суффикс -constants.

⭐ Путь к файлу с константами: /common/constants/{NAME}-constants.ts

⭐ Каждая константа: должна иметь ключевое слово export, должна содержать описание(см. примеры констант)

Вызов констант осуществляется через импортimport { LOGIN_ERROR_MESSAGE } from '../common/constants/auth-constants';

⭐ Константы, относящиеся к конкретной фиче (тексты, названия, неизменяемые массивы, числа и т. д.), выносятся в отдельный файл common/constants/{feature-name}-constants.ts

В константах также удобно хранить мок-ответы на API-запросы в виде функций, где через аргументы можно передавать только те значения, которые нужно изменить в ответе. Путь к файлу хранящегося замоканые ответы может быть /constants/mocks/user-profile-constants.ts или /constants/profile-constants.ts.

Пример замоканного ответа
Пример замоканного ответа
Пример замоканного ответа

Также ниже показан пример вызова функции функцииgetUserProfileSettingsMockкоторая возращает замоканный риспонз

пример вызова функцииgetUserProfileSettingsMock
пример вызова функцииgetUserProfileSettingsMock

⚠️ Кстати, если в api тестах у вас есть проверки на схему то схемы -> ТО лучше все схемы (структуры ответов) хранить в подпапке внутри констант ./common/constants/shemas/{FEATURE}

Иерархия схем
Иерархия схем

Например, в order-shemas.ts будет храниться ожидаемая структура ответа на запрос за получением детальной информации по заказу

Примеры
// common/constants/shemas/order-shemas/orderResponse.schema.ts

export const LAST_ORDER_BY_USER_ID = [
  {
    id: expect.any(Number),
    payedAt: expect.any(String),      // ISO date string
    createdAt: expect.any(String),    // ISO date string
    isDelivered: expect.any(Boolean),
    address: expect.any(String),
    status: expect.any(String),
  },
];

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

// common/constants/shemas/order-shemas.ts

export const getOrderSchema = (orderNumber: string) => ({
  [orderNumber]: [
    {
      id: expect.any(Number),
      payedAt: expect.any(String),      // ISO date string
      createdAt: expect.any(String),    // ISO date string
      isDelivered: expect.any(Boolean),
      address: expect.any(String),
      status: expect.any(String),
      userId: expect.any(Number),
    },
  ],
});


// ассерт в тесте
expect(json).toMatchObject(getOrderSchemaB("KZ-12345-ONLINE"));

📌 Helpers. ⚠️ Не путать с playwright-helpers.

Helpers — это файлы с дополнительными кастомными функциями или методами, которые используются для получения или преобразования данных в тестовой структуре.

В проекте хелперы находятся в директории common(support). Ниже показано дерево хелперов в проекте.

Пример дерева хелперов
Дерево хелперов
Дерево хелперов

Особенности:

  • Helpers не всегда зависят от функций фреймворка.

  • Это место в проекте, где размещаются самописные функции/методы, которые затем могут использоваться в components, pages, steps или tests.

Хелперы нужны для:

  1. Повторного использования кода – позволяют вынести часто используемые функции (например, генерация тестовых данных, преобразование дат) в одно место, чтобы не дублировать их в тестах.

  2. Упрощения тестов – тесты становятся чище и понятнее, когда в них нет громоздкой логики.

  3. Логического разделения ответственности – хелперы отделяют бизнес-логику тестов от вспомогательных операций.

  4. Поддерживаемости – изменения в одном хелпере автоматически применяются во всех тестах, где он используется.

  5. Инкапсуляции сложных действий – скрывают реализацию внутри функции, упрощая её вызов.

  6. Сокращения времени разработки – ускоряют написание тестов за счёт готовых переиспользуемых утилит.

Правила для хелперов:

  1. Имя файла:

    • Должно быть в формате kebab‑case.

    • Должно заканчиваться на суффикс -helper.

    • Все буквы — строчные, слова разделяются дефисами.

    • Путь к файлу с хелперами в тестовом проекте: /common/helpers/{NAME}-helper.ts

    Пример: date-transform-helper.ts, user-profile-helper.ts

  2. Функции хелпера:

    • Каждая функция должна иметь ключевое слово export.

    • Каждая функция должна содержать описание в виде JSDoc‑комментария.

Примеры использования:

  • Генерация данных для тестов

  • Преобразование или вычисления (например, конвертация дат или вычисление разницы между ними).

  • Самописные функции с использованием функций фреймворка.

Примеры

На картинке ниже показан показан пример функции которая возращает значение по ключуcommon-helper.ts

Функция возращающая значение по ключу из объекта
Функция возращающая значение по ключу из объекта

На 2 картинке показан пример получения данных о профиле пользователя по его ID. Функция хранится в файле user-profile-helper.ts

Функция возращающая информацию о профиле по ID пользователя
Функция возращающая информацию о профиле по ID пользователя

На 3 картинке показана функция возращающая разницу между датами. Функция хранится в файле date-format-helper.ts

Функция возращающая разницу между датами
Функция возращающая разницу между датами

⚠️ Хелперы также можно использовать для API-запросов.

Вместо того чтобы каждый раз не дублировать шаблон HTTP запросов, можно создать базовую функцию, которая принимает , data , queryParams, а также выбрасывает исключение, если ответ вернулся не с кодом 200. Для этого, например в папке helpers создадим файл request-helper.ts.

Читаем подробнее (Код и пример вызова функции doRequest(...))

Далее в нем нужно написать функцию например doRequest, которая принимает метод, эндпоинт, contentType, универсальный queryBody (или params/ form), токен если нужно, дополнительные хедеры

Внутри функции doRequest:

  • Заголовки: укажите что в опциях запроса fetch должны быть указаны базовые хедеры запроса (options.headers) как Authorization || ContentType и также должна быть возможность добавлять дополнительные хедеры.

  • Параметы: В опциях запроса нужно явно указывать, что используется в качестве данных:

    • Если метод относится к POST, PUT, PATCH, то тело запроса обязательно должно быть не пустым и передаётся как data / form / multipart в зависимости от формата.

    • Если метод относится к GET или DELETE, то тело запроса не используется, вместо него можно передать параметры запроса (params), которые будут преобразованы в query-строку.

  • формируем полный url запроса внутри функции doRequest

  • теперь нужно выполнить HTTP-запрос через fetch, обернуть его в try/catch и вернуть объект, содержащий код ответа (status) и тело ответа в формате JSON

  • сделаем чтобы функция возращала код ответа на запрос и сам ответ в json виде

  • далее когда функция готова -> переиспользуем ее для другой функции которая например будет выполнять GET запрос

// функция для отправки запроса
import {APIRequestContext} from "playwright";

const HOST = 'https://example.com/'

const queryOptions = ['params', 'form-data', 'x-www-form-urlencoded']


export async function doRequest(
    request: APIRequestContext,
    method: string,  // HTTP метод
    endpoint: string, // название ендпоинта
    payload: string, // вид payload
    lang?: string,
    platform?: string,
    token?: string, // токен
    queryContent?: any, // тело запроса или параметры
    headers?: any // дополнительные заголовки
) {
    // не пустая строка и не null/undefined
    let userToken = token && token !== '' ? token : null;

    const authType = 'Bearer' // или Basic или другое и т.п

    const options = {
        method,
        headers: {
            'app-platform': platform,
            'app-language': lang,
            'accept': 'application/json',
            'Authorization': authType + ' ' + userToken,
            ...headers, // добавление и объединение дополнительных хедеров
        },
        [payload]: queryContent, // тело запроса или параметры
    }

    if (
        payload === "body" &&
        queryContent &&
        ["POST", "PUT", "PATCH"].includes(method.toUpperCase())
    ) {
        options.body = JSON.stringify(queryContent);
    }

    if (
        queryOptions.includes(payload) &&
        ["GET", "DELETE"].includes(method.toUpperCase())
    ) {
        options.payload = JSON.stringify(queryContent);
    }

    let url = HOST + endpoint

    try {
        const res = await request.fetch(url, options);

        return {
            status: res.status(),
            jsonResponse: await res.json()
        };
    } catch (error) {
        return {
            data: {
                error: "Request failed",
                options: options
            }
        };
    }
}


export async function doGETRequest(
    request: APIRequestContext,
    endpoint: string, // название ендпоинта
    queryContent?: any, // тело запроса или параметры
    lang?: string,
    platform?: string,
    token?: string, // токен
    headers?: any, // дополнительные заголовки

) {

    return await doRequest(
        request,
        'GET',
        endpoint,
        'params',
        lang,
        platform,
        token,
        queryContent,
        headers
    )
}

Вызов doGETRequest в тесте:

//вызов doGETRequest для отправки запроса в тесте

test('Пример вызова doGETRequest', async ({ request }) => {

  const response = await doGETRequest(
          request,
          'api/getText',
          {
            'isbn': '1234',
            'page': 23,
          },
          'ru',
          'frontend',
      )
  
      //console.log(await response.jsonResponse)
  
    expect(response.status).toBe(200)
    expect(await response.jsonResponse.items.length).toBeGreaterThan(0)
})

⚠️ В хелперах можно также поместить методы возращающие замоканный ответ или же вынести моки в отдельную подпапку /common/helpers/mocks/book-helper.ts

Примеры: Пример с офф. сайта

📌 Locators

Locators позволяют находить элементы на веб-страницах. Они играют ключевую роль в автоматизации тестирования UI, потому что:

  • Обеспечивают возможность идентифицировать и взаимодействовать с конкретными элементами DOM (Document Object Model).

  • Позволяют автоматизировать действия на веб-страницах независимо от мелких изменений в дизайне или верстке.

  • Делают тесты более надёжными и повторяемыми, предоставляя стабильный способ взаимодействия с элементами интерфейса.

Правила именования:

  • Имя файла должно быть в формате kebab-case.

  • Имя файла должно заканчиваться на суффикс -locators.

  • Все буквы — строчные, слова разделяются дефисами.

⭐ Путь к файлу с локаторами в тестовом проекте: /common/locators/{NAME}-locators.ts . Древо локаторов для тестиков показано на картинке ниже.

Пример дерева файлов с локаторами
Древо локаторов
Древо локаторов

Примеры (см. скрытый текст):

Пример локаторов

На картинке ниже показан пример XPath с использованием data-test-id для поиска элемента списка 

Пример XPath с использованием data-test-id и ul
Пример XPath с использованием data-test-id и ul

На второй картинке ниже показан пример XPath с использованием HTML атрибута id

Пример XPath с использованием  аттрибута  id
Пример XPath с использованием аттрибута id

На третьей картинке показан пример получение локатора темы по ее имени

Пример функции, которая возвращает XPath‑локатор темы по её имени
Пример функции, которая возвращает XPath‑локатор темы по её имени

⭐ Для автоматизации UI‑тестов предпочтительно использовать локаторы на основе атрибутов data-test-id или data-test. ( Конечно, допускается отклонение от этого правила только в случаях, когда добавить такие атрибуты невозможно 😉, но лучше так не делать)

Тестирование по test id - это самый надёжный способ автоматизации, потому что даже если текст или роль элемента изменятся, тест всё равно будет проходить. Вызов осуществляется через метод page.getByTestId()

Test-id позволяет избежать нестабильности при поиске локаторов. Это важно, так как нестабильность часто возникает при использовании class, id, name или text, которые могут измениться или быть удалены разработчиками, либо значение хешируемые, что приводит к падению тестов. (Хорошая статья Можно почитать дополнительно о ++ test-id)

Например:

  • ❌ Использование атрибутов class, id, placeholderможет привести к падению теста если значение атрибута изменено или хешируемоclass="email-123-6gdhfge77 может измениться на class="email-123-hdvbcv

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

А вот test-id не зависит от текста, стилей и структуры DOM или смены текста. ( CSS-классы могут быть переименованы или хешированы , a XPath часто ломается даже при незначительных изменениях DOM.)

Поиск через page.getByTestId() короче, понятнее и надёжнее, чем использование сложных XPath или цепочек CSS, что существенно сокращает количество правок при рефакторинге UI и делает тесты более стабильными.

⚠️ Однако есть исключения, когда можно НЕ использовать test-id:

  • Если локатор по классу имеет понятное и читаемое значение, его допустимо использовать. Пример: class="email", id="email" — то такой селектор можно применять в тестах. (Но минус такого использования в том, что локатор может быть удалён или изменён, что приведёт к падению теста.)

  • Можно использовать Playwright "the recommended built-in locators" Читаем в офф доке

Где хранить все используемые test-ids?

Лучший способ — хранить все используемые test-id в одном централизованном файле констант в основном проекте, например: /src/constants/test-ids.ts

Добавление test-id : data-test-id={PROFILE.EDIT_BUTTON}, data-testid={PROFILE.EDIT_BUTTON}

Название test-id должно быть уникальным, то есть "один data-test-id к одному элементу"

Пример хранения test-ids в проекте
 Пример test-ids в /src/constants/test-ids.ts
Пример test-ids в /src/constants/test-ids.ts

Ниже показан пример добавления test-id к элементу

<button data-test-id={PROFILE.EDIT_BUTTON}>Edit Profile</button>
<div data-test-id={PROFILE.THEME_LIST}>
<a data-test-id={PROFILE.SECURITY_LINK} href="/profile/security">
  .....
</a>

Пример использования test-id через вызов getByTestId в PageObject || Components:

Пример использования test-id в методе editProfile
Пример использования test-id в методе editProfile

📌 ApiRoutes

ApiRoutes эта директория это директория, в которой лежит один или болле файлов с описанием всех API эндпоинтов, которые используются в автотестах.

Файл(ы) внутри директории аpiRoutes содержит(-ат) пути к API. Причины того что все пути API хрянятся в специальзировано отведеном файле следущие:

  • централизованно управлять изменениями путей API запросов ( храним, читаем меняем в одном месте так как все пути API в одном месте).

  • делаем код читаемым и менее склонным к опечаткам ( легче менять значение константы, при смене версии API (например, v1v2) правим только в одном файле)

❗API-пути могут храниться: в одном файле или нескольких.

Ниже показан пример где все API ендпоинты лежат в одном файле например api-routes.ts

Пример содержимого файла api-routes
/**
 * Эндпоинты для авторизации
 */
export const AUTH = {
  login: '/api/auth/login',
  restorePassword: '/api/auth/restore-password',
};

/**
 * Эндпоинты для работы с профилем пользователя
 */
export const USER_PROFILE_SETTINGS = {
  updateProfile: '/api/user/profile/update',
  updateAvatar: '/api/user/profile/avatar/update',
};

Ниже показан пример где все API ендпоинты разделены по модулям в нескольких файлах.

apiRoutes/
  ├── authorization-routes.ts
  ├── user-profile-routes.ts
  └── ...

Правила по оформлению:

  1. Добавляейте описание к константе значение которой содержит путь API ендпоинта

  2. экспортируйте эту константу через export

Ниже можно посмотреть пример вызова эндпоинтов из USER_PROFILE_SETTINGS

Пример api запроса в helpers
import { APIRequestContext, expect } from '@playwright/test';
import { 
USER_PROFILE_SETTINGS 
} from './common/apiRoutes/api-routes.ts'; 

export async function updateProfile(request: APIRequestContext) {
  try {
    const response = await request.post(
      HOST + USER_PROFILE_SETTINGS.updateProfile,
      {
        headers: {
          'Authorization': '...',
          'Content-Type': '...'
        },
        data: {
          name: '...',
          // другие поля профиля
        }
      }
    );

    expect(response.status()).toBe(200);

    const jsonData = await response.json();

    expect(jsonData).toBeTruthy();
    return jsonData;

  } catch (error) {
    console.error('API request failed:', error);
    throw error;
  }
}

📌 Components (Component Object / Page Component)

Часто бывает, что некоторые страницы содержат общий интерфейс — повторно используемые UI-компоненты, такие как шапка сайта, всплывающее уведомление, модальное окно, фильтры и т. п. Эти элементы не имеют собственной страницы или уникального URL, но присутствуют на нескольких или даже на всех страницах приложения.

👉 И так разберемся что такое Component Object?

Component Object / Page Component - это класс, описывающий переиспользуемый компонент страницы, из которых составляется Page Object.

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

Цель такого подхода - работать не со страницей целиком ( не в page object-ах), а с её конкретной частью (component object-ах), которая повторяется на разных страницах (с разными URL). Это позволяет вынести логику работы с общими элементами в отдельные компоненты и переиспользовать их в любых тестах.

Путь к файлу с компонентами в тестовом проекте: /common/components/{NAME}Component.ts. или /common/components/{componentFolder}/{NAME}Component.ts

Пример древа компонентов в проекте для тестов будет выглядеть следующим образом в директории с Playwright-тестами.

Пример структуры компонентов
Древо компонентов в проекте для тестов
Древо компонентов в проекте для тестов

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

Создание компонента и его содержимое ( пояснения и примеры).

Есть 3 пути создания класса компонента:

1. Создаем класс без наследования. В этом подходе мы описываем компонент как самостоятельный класс, не зависящий от базового Base.

Почему не использую наследование? ( раскройте в скрытый текст, где приведены причины отказа от наследования и пример реализации компонента без базового класса)

Плюсы создания класса компонента без наследования с примером
  1. Изоляция - класс-компонент не зависит от изменений в Base-классе, поэтому правки в базовом классе не сломают его работу. Он использует собственный конструктор, который позволяет гибко управлять передаваемыми параметрами и прямо внутри компонента инициализировать только те локаторы, которые действительно нужны.

  2. Читаемость - сразу видно, что компонент работает только с теми локаторами, которые относятся именно к нему.

  3. Лёгкая переиспользуемость - компонент можно вызвать в любом тесте, Page Object или Step Object без подтягивания всей иерархии классов.

  4. Меньше скрытой логики – все действия и локаторы определены локально, нет «магии» родителя, когда часть функционала спрятана в Base.

Пример: есть сайт с общим компонентом, который присутствует на всех страницах, например — меню. Конструктор объявлен внутри класса.

import {Locator, Page} from "playwright";
import {BASE_URL} from "../config/endpoints";

/**
 * Компонент общего меню, который доступен на всех страницах сайта
 */
export class MenuComponent {
    readonly page: Page;
    readonly docs: Locator;
    readonly menuButton: Locator
    readonly guide: Locator

    /**
     * Конструктор
     * Для примера значения селекторов(локаторов) указала просто туть :)
     */
    constructor(page: Page) {
        this.page = page;
        this.menuButton = page.getByTestId('menuBurger')
        this.docs = page.getByRole('link', { name: 'Docs' })
        this.guide = page.getByRole('button', { name: 'Guides'})
    }

    /**
     * Раскрывает меню
     */
    async expandMenu() {
        await expect(this.menuButton).toBeVisible()
        await expect(this.menuButton).toHaveAttribute('aria-expanded', 'false')
        await this.menuButton.hover()
        await this.menuButton.click()
        await expect(this.menuButton).toHaveAttribute('aria-expanded', 'true')
    }

    /**
     * Выберает из главного меню секцию Docs
     */
    async goToDocs() {
        await expect(this.docs).toBeVisible()
        await this.docs.hover()
        await this.docs.click()
        await expect(this.page).toHaveURL(BASE_URL + '/docs/intro')
    }
}

2) создаем класс компонента, унаследовав конструктор и остальное содержимое с базового класса Base.

При наследовании page из Base (где уже есть конструктор) локаторы в компоненте можно не хранить как свойства класса -> их объявляют прямо внутри методов.

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

Плюсы насследование конструктора базового класса в классе компоненте, с примерами

Плюсы объявления локаторов прямо внутри методов при наследовании page из Base:

  1. Меньше кода -> не нужно дублировать поля класса для локаторов, если они используются всего в одном методе.

  2. Простая читаемость метода -> локаторы видны прямо там, где с ними происходит работа.

  3. Локальная область видимости -> локатор «живёт» только внутри метода и не засоряет структуру класса.

  4. Меньше риска неиспользуемых свойств -> в классе не будет полей, которые нужны только для одной операции.

  5. Гибкость для разовых случаев -> можно легко изменять селектор под конкретный шаг, не влияя на другие методы.

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

Класс компонент который наследует конструктор от базового класса

import {Base} from "../Base";
import {expect} from "@playwright/test";
import {BASE_URL} from "../config/endpoints";

/**
 * Компонент общего меню, который доступен на всех страницах сайта
 */
export class MenuComponent extends Base {
    /**
     * Раскрывает меню
     */
    async expandMenu() {
        const menuButton = this.page.getByTestId('menuBurger')

        await expect(menuButton).toBeVisible()
        await expect(menuButton).toHaveAttribute('aria-expanded', 'false')
        await menuButton.hover()
        await menuButton.click()
        await expect(menuButton).toHaveAttribute('aria-expanded', 'true')
    }

    /**
     * Выберает из главного меню секцию Docs
     */
    async goToDocs() {
        const docs = this.page.getByRole('link', { name: 'Docs' })

        await expect(docs).toBeVisible()
        await docs.hover()
        await docs.click()
        await expect(this.page).toHaveURL(BASE_URL + '/docs/intro')
    }
}

3) создаем класс компонента наследуя базовый класс (чтобы получить page и общие методы) НО добавляем собственный конструктор внутри компонента, чтобы инициализировать локаторы, специфичные только для этого компонента. (см.скрытый текст)

Плюсы объединенного подхода: наследование конструктора из базовового класса и добавление собственного конструктора в класс компонент

Как это работает?

  1. Класс-компонента расширяет Base (базовый класс), поэтому в нём доступно всё, что есть в Base - например, свойство page.

  2. Внутри Класса-компонента мы создаём свой конструктор с параметром page, и вызываем super(page), чтобы передать его в базовый класс.

  3. После вызова super(page) мы можем инициализировать уникальные локаторы для этого компонента (menuButton, docs).

Плюсы этого подхода (Рекомендованный) :

  • Сохраняем преимущества наследования. Наследуем общий функционал Base ( общие методы, ожидания, и т. п.) и можем напрямую использовать эти наследованные методы .

  • У класса компонента остаются свои поля и собственная их инициализация в конструкторе.
    Уникальные для компонента локаторы инициализируются в его конструкторе. Код становится чище, а логика — локальной к компоненту, без засорения Base.

  • Cоздание локаторов только один раз.
    Локаторы создаются один раз при инициализации компонента, а не в каждом методе - меньше повторов.

import {Base} from "../Base";
import {expect, Page} from "@playwright/test";
import {BASE_URL} from "../config/endpoints";
import {Locator} from "playwright";

export class MenuComponent extends Base {
    private readonly menuButton: Locator
    private readonly docs: Locator

    constructor(page:Page) {
        super(page)
        this.menuButton = this.page.getByTestId('menuBurger')
        this.docs = this.page.getByRole('link', { name: 'Docs' })
    }

    /**
     * Раскрывает меню
     */
    async expandMenu() {
        //const menuButton = this.page.getByTestId('menuBurger')

        await expect(this.menuButton).toBeVisible()
        await expect(this.menuButton).toHaveAttribute('aria-expanded', 'false')
        await this.menuButton.hover()
        await this.menuButton.click()
        await expect(this.menuButton).toHaveAttribute('aria-expanded', 'true')
    }

    /**
     * Выберает из главного меню секцию Docs
     */
    async goToDocs() {
        //const docs = this.page.getByRole('link', { name: 'Docs' })

        await expect(this.docs).toBeVisible()
        await this.docs.hover()
        await this.docs.click()
        await expect(this.page).toHaveURL('https://playwright.dev/docs/intro');

    }
}

Правила наименования и группировки компонентов используемых для автотестов:

1) Наименование: используйте формат PascalCase и добавляйте окончание Component. Пример: HeaderComponent.ts

Лучше хранить код компонентов в отдельных, чётко названных файлах, название которых отражает назначение каждого компонента. Эти компоненты следует повторно использовать в page objects и E2E-тестах.

2) Группирование: Объединяйте схожие по назначению компоненты в общую директорию. Пример: в директории /common/components/alerts/ находится два компонента как WarningComponent и ErrorAlertComponent

Ниже показаны примеры, в кот Alert компонентов и Coockies (см. скрытый текст) наследующие page от базового Base класса

Примеры содержимого в классах компонентах
  • Alert (WarningComponent) — набор компонентов для работы с уведомлениями/алертами (например, предупреждение, ошибка).

// common/components/alerts/WarningComponent.ts
import { Page, Locator, expect } from '@playwright/test';
import { Base } from 'common/base/Base';
import { CLOSE_ALERT, WARNING_ICON } from '../test-ids';

export class WarningComponent extends Base {
  private readonly icon: Locator
  private readonly closeBtn: Locator

  constructor(page:Page) {
      super(page)
      this.icon = this.page.getByTestId(WARNING_ICON)
      this.closeBtn = this.page.getByTestId(CLOSE_ALERT)
  }

  /** Проверка видимости иконки предупреждения */
  async assertIconVisible() {
    await expect(this.icon).toBeVisible();
  }

  /** Закрыть алерт и убедиться, что он исчез */
  async dismissAlert() {
    if (await this.closeBtn.isVisible()) {
      await this.closeBtn.click();
    }
  }
}
  • SiteCookies — компонент для работы с cookie-баннером.

// common/components/SiteCookiesComponent.ts
import { Locator, Page, expect } from '@playwright/test';
import { Base } from 'common/base/Base';
import { 
  ACCEPT_COOKIES_BTN,
  REJECT_COOKIES_BTN,
  COOKIES_BANNER 
} from '../test-ids';

export class SiteCookiesComponent extends Base {
  private readonly banner: Locator;
  private readonly acceptButton: Locator;
  private readonly rejectButton: Locator;

  constructor(page: Page) {
    super(page);
    this.banner = this.page.getByTestId(COOKIES_BANNER);
    this.acceptButton = this.page.getByTestId(ACCEPT_COOKIES_BTN);
    this.rejectButton = this.page.getByTestId(REJECT_COOKIES_BTN);
  }

  /** Проверка, что баннер виден */
  async assertBannerVisible() {
    await expect(this.banner).toBeVisible();
  }

  /** Принять cookies (если баннер есть) */
  async acceptCookies() {
    await expect(this.acceptButton).toBeVisible();
    await this.acceptButton.click();
    await this.assertBannerNotVisible();
  }

  /** Отклонить cookies (если баннер есть) */
  async rejectCookies() {
    await expect(this.rejectButton).toBeVisible();
    await this.rejectButton.click();
    await this.assertBannerNotVisible();
  }

  /** Проверить, что баннер не отображается */
  async assertBannerNotVisible() {
    await expect(this.banner).toBeHidden();
  }
}

📌 Pages (Page Object Model)

Что такое шаблон?

Шаблон - это проверенное решение, которое масштабируемо и гибкое для решения определеной задачи.

Одним из таких шаблонов для проектирования автотестов является Page Object Model (модель объектов страницы). Этот шаблон помогает организовать вспомогательную логику для автотестов, разделяя всё приложение на отдельные страницы.

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

⚠️ Важно! Как не перепутать что выносить в компоненты, а что оставить в пейдже и что для чего предназначено?

PageObject - это корневой объект страницы:

  • у него есть роут (часть url) и общую навигацию

  • описывают композицию компонентов, из которых состоит страница

  • страница импортирует компоненты

  • Пример: LoginPage.

Сomponent - это переиспользуемый фрагмент UI:

  • не имеет собственного роута (url)

  • может встречаться на разных страницах

  • компоненты не импортируют страницы (зависимость всегда от компонента к странице, а не наоборот).

  • Пример: модалки, панели, таблицы, хедер, форма поиска, карточка товара.

❗В проекте с автотестами есть папка pages, лежащая в директории common(support).

Каждая страница приложения должна иметь свой собственный класс PageObject в папке /pages/{PageName}Page.ts или подпапке ./pages/{feature}/{PageName}Page.ts

Правила наименования и группировки классов:

1) Название класса PageObject должно точно описывать, для какой страницы приложения он предназначен.

Формат имени класса PageObject: PageName + окончание Page

Примеры о том почему важно избегать конфликта имен классов PageObject

Причина почему добавляется окончание Page в название класса следующая: избежать конфликта имен.

При импорте сразу видно, что этот класс, реализующий PageObject для автотестов, то есть благодаря окончанию Page у классов PageObject НЕ ВОЗНИКНЕТ конфликтов имён.

// Pages/LoginPage.ts
export default class LoginPage { ... }

// Steps/Login.ts
export default class Login { ... }

// Импорт
import LoginPage from '../pages/LoginPage';
import Login from '../steps/Login'; //(Но это выдуманое название 
//так как в начале статьи написаны правила наименования
//в папке common(support))

describe('Login page tests', () => {
  const pageObject = new LoginPage();
  const stepObject = new Login();
});

Как видно в примере выше, не нужно переопределять название класса при импорте, даже если в проекте есть другой класс с таким же именем, но в другой области (например, в steps или services(helpers)).

То есть, например, нужен класс PageObject для страницы профиля пользователя. Значит нужно назвать класс чтоб он отражал общий смыл для какой страницы этот класс будет использоваться.

2) Объединение (группировка) pageObject в подпапки в директории /common/pages.

  • Если одна страница приложения содержит несколько логически обособленных частей и содержимое класса становится слишком громоздким, то можно содержимое разделить на логически схожие по смыслу части и вынести эти части в другие классы, которые будут лежать в отдельной подпапке /common/pages/{feature}/{PageName}Page.ts

    Такие классы объединяются в общую папку внутри директории pages. Смотрите пример на картинке ниже:

Пример, группирование схожих по смыслу классов в отдельную подпапку в ../pages
Пример дерева pageObject-ов для страницы профиля
Пример дерева pageObject-ов для страницы профиля
  • Если страница простая и на ней мало логики и количество методов не тьма :) - то и не нужно этот класс PageObject выносить в отдельную подпапку в /common/pages Просто оставляем класс в /common/pages/{PageName}Page.ts

3) Название папки или набора описывает, для каких страниц он используется.

Название папки, объединяющей логически связанные PageObjects, должно быть в PascalCase.

Пример наименования папки

Пример:

Authorization - папка, которая объединяет все страницы регистрации, логина и сброса пароля.

UserProfile - папка, которая объединяет все страницы профиля пользователя, настроек безопасности и редактирования профиля.

Создание класса PageObject и его содержимое (пояснения и примеры).

И так теперь нужно же после всей это бла-бла-бла создать пример PageObject.

Допустим у нас есть старница с простой логикой. Создаем файл с форматом ts в /common/pages/{PageName}Page.ts

В примере будет страница логина:

1) Создали класс PageObject для страницы логина : export class LoginPage в папке

Далеее нам нужно как то указать инструкцию на подобие конструктура но вспомним что ранее у на есть класс Common в /common/base/Base.ts в котором уже есть конструктор.

Наследуем export class LoginPage extends Base.

Теперь переходим к наполнению нашего класса UserProfilePage.

Но вот на этом этапе возникает вопрос: нужно ли добавлять конструктор в класс PageObject, если в Base он уже реализован? 🤔

Ответ на вопрос:

1-вариант) Если в PageObject НЕ НУЖНО объявлять собственные поля, конструктор можно не писать -> так как, он уже есть в базом классе Base и получит page из теста.

2-вариант) Если в PageObject нужно объявлять собственные поля, конструктор обязательно нужно писать. Пример: офф. пример

3-вариант) Можно комбинировать, то есть наследовать класс от базового класса и добавить собственный конструктур в класс для работы со страницей (то есть объединяем 1 и 2 подход)

Пример класса pageObject, в котором наследуются свойства и методы базового класса и добавлен собственный конструктор ( 3 - вариант)
/common/pages/LoginPage.ts
import { Page, expect, Locator } from '@playwright/test';
import {Base} from "../Base";
import {BASE_URL} from "../config/endpoints";

export class LoginPage extends Base {
    readonly usernameInput: Locator
    readonly passwordInput: Locator
    readonly submitButton: Locator

    constructor(page: Page) {
        super(page);
        this.usernameInput = this.page.getByTestId('login-username');
        this.passwordInput = this.page.getByTestId('login-password');
        this.submitButton = this.page.getByTestId('login-submit');
    }

    async open() {
        const loginUrl = BASE_URL + '/login'

        await this.page.goto(BASE_URL + '/login');
        await expect(this.page).toHaveURL(loginUrl)
    }

    async fillUsername(username: string) {
        await expect(this.usernameInput).toHaveValue('')
        await this.usernameInput.fill(username);
    }

    async fillPassword(password: string) {
        await expect(this.passwordInput).toHaveValue('')
        await this.passwordInput.fill(password);
    }

    async submit() {
        await expect(this.submitButton).toBeEnabled()
        await this.submitButton.click();
    }
}

⚠️ Правила работы с элементами внутри класса PageObject:

1) Локаторы и константы. В классе PageObject храните только переменные с присвоенными локаторами. То есть, сами значения локаторов и констант выносите в отдельные файлы в папке /constants для констант , /locators для локаторов. Это упрощает поддержку и уменьшает дублирование

Пример

Например:const loginButton = page.getByRole(ROLES.button, { name: buttonName })

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

// метод в LoginPage.ts
async clickLoginButton(buttonName: string) { 
  const loginButton = page.getByRole(ROLES.button, 
     { name: buttonName })
   .....
}

// сам тест
import { BUTTONS }from '/common/constants/element-constants.ts'

test('Логин...', async ({ page }) => {
  const loginPage = new LoginPage(page);
   .....

  await loginPage.clickLoginButton(BUTTONS.submit);

  await expect(page).toHaveURL(EXPECTED_URL);
});

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

Пример

Например, async fillPassword(password: string) - ✅, так как метод выполняет одно конкретное действие. А вот async fillCredsAndSubmit(password: string, email: string, buttonName: string) - ❌, потому что он объединяет несколько действий: заполнение полей ввода в форме логина и нажатие кнопки авторизации.

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

3) Проверка видимости. Перед любым действием (клик, ввод текста и т.п.) проверяйте, что элемент доступен и видим, редактируемый (toBeVisible, toBeEditable, toBeEnabled). Это снизит количество нестабильных тестов.

Пример

// Проверяем, что кнопка отображается, затем кликаем
const submitButton = page.locator('button.submit');
await expect(submitButton).toBeVisible();
await submitButton.click();

// Проверяем, что поле ввода доступно для редактирования, затем заполняем
const textField = page.getByRole('textbox');
await expect(textField).toBeEditable();
await textField.fill('значение');

// Проверяем, что кнопка активна, затем кликаем
const enabledButton = page.locator('button.submit');
await expect(enabledButton).toBeEnabled();
await enabledButton.click();

4) Понятные имена методов. Метод должен отражать суть действия, то есть для чего он предназначен. Пример: clickLoginButton() вместо clickButton().

5) Описание методов. Добавляйте docstring к методам , поясняющий "Что делает метод"

6) Работа с текстовыми локаторами. Избегайте поиска локатора напрямую по тексту. Если без этого нельзя, то -> НЕ ХАРДКОДЬТЕ текст прямо в PageObject - ❌, а передавайте значение текста в метод через аргумент - .

Пример
// сам тест
import { BUTTONS }from '/common/constants/element-constants.ts'

test('Логин...', async ({ page }) => {
  const loginPage = new LoginPage(page);
   .....

  await loginPage.clickLoginButton(eBUTTONS.submit);

  await expect(page).toHaveURL(EXPECTED_URL);
});

Разделение ответственности в схожих PageObject

7) Разделение ответственности в схожих PageObjects. Не добавляйте методы, которые относятся к другой странице (родительской или дочерней). Лучше создать новый PageObject для этой страницы.

Пример
// LoginPage.ts
export class LoginPage {
 ......
  async fillPassword(password: string) {
    await expect(this.passwordField).toBeVisible();
    await this.passwordField.fill(password);
  }

  async restorePassword() {
    await expect(this.restoreButton).toBeEnabled();
    await this.restoreButton.click();
    return new RestorePasswordPage(this.page); // -> Переходим 
     // на другую страницу, и методы по востановлению пароля
    // уже описываем в другом классе PageObject в RestorePasswordPage
  }
}

......
// RestorePasswordPage.ts
export class RestorePasswordPage {
 ....
  async enterRestoreCode(code: string) {
    await expect(this.codeField).toBeVisible();
    await this.codeField.fill(code);
  }
....
}

8) Не пишите статические методы в классе PageObject.

  • Не используйте static для методов PageObject. Каждый объект страницы должен работать с экземпляром, чтобы было проще управлять состоянием.

Пример

Неправильно:

// LoginPage.ts
export class LoginPage {
   ....

  static async fillEmail(page: Page, email: string) {
    await page.fill(this.emailField, email);
  }

  static async fillPassword(page: Page, password: string) {
    await page.fill(this.passwordField, password);
  }
}

// сам тест,
//где приходится в каждый метод передавать page.
// PageObject перестаёт быть объектом страницы
test('Логин через static методы', async ({ page }) => {
  await LoginPage.fillEmail(page, 'test@example.com');
  await LoginPage.fillPassword(page, '123456');
});

Правильно:

// LoginPage.ts
export class LoginPage {
  ....

  async fillEmail(email: string) {
    await this.emailField.fill(email);
  }

  async fillPassword(password: string) {
    await this.passwordField.fill(password);
  }
}

// сам тест
// где page хранится в экземпляре, 
// не нужно передавать его в каждый метод
test('Логин', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.fillEmail('test@example.com');
  await loginPage.fillPassword('123456');
});
  • Если метод можно сделать статическим (например, он возвращает константу или локатор), значит он не зависит от страницы и может быть вынесен в отдельный файл

Пример

Например, если функция возвращает локатор по аргументу:

// ❌ Плохо - в PageObject
static getButtonByName(name: string) {
  return 'button[name="${name}"]'';
}
// ✅ Хорошо — в locators/commonLocators.ts
export function getButtonByName(name: string) {
  return 'button[name="${name}"]'';
}

📌 Step pattern

(не путать с test.step в Playwright)

Определённую последовательность вызова функций или методов можно рассматривать как сценарий, где каждый метод является отдельным шагом.

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

Например, для e2e или API теста - также используется последовательность шагов

  • API: оформление заказа: отправляем запрос на добавление выбранного товара в корзину -> затем запрос на указание способа и адреса доставки, а также метода оплаты -> далее запрос на проведение оплаты -> после этого запрос на получение статуса заказа.

  • E2E: сценарий оформления заказа - выбираем товар и его количество, добавляем в корзину, переходим в корзину, указываем адрес и способ доставки, выбираем способ оплаты, совершаем оплату и в итоге получаем статус заказа.

Когда совокупность шагов повторяется в автотестах более одного раза, её стоит вынести в step pattern (шаги), чтобы не дублировать код и повысить читаемость.

❗Step pattern помогает:

  • объединить методы / функции в логические последовательности действий (например: 'авторизация пользователя', 'оформление заказа') и переиспользовать эту последовательность более 1 раза

  • Избегать дублирования последовательностей действий в тестах, следуя принципу DRY.

Когда нужен степ? - тогда когда есть определеная последовательность методов которые повторяются более 1 раза в автотестах

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

Правило именования файлов со степами

  • Файл со степами должен называться в формате kebab-case: все буквы строчные, слова разделяются дефисами.

  • В конце имени файла обязательно добавляется окончание step.

  • Название файла должно отражать область/фичу, для которой описаны шаги

Пример

order-step.ts - это файл, содержащий степы для оформления заказа

Где хранить степы? - Все файлы степами должны храниться в директории common/steps/

Правила и примеры
  • Если для одной фичи накапливается слишком много логики, которую можно разделить на более мелкие части -> ТО разбейте её на несколько файлов со степами.

    Путь: /common/steps/e2e(api)/{feautureFolder}/{feautureName}-step.ts-> если есть несколько файлов со степами по одной большой фиче.

    Все такие файлы должны находиться в одной подпапке, соответствующей конкретной фиче ({featureFolder}) внутри каталога steps/e2e или steps/api (в зависимости от типа тестов).

Пример когда есть степы и для UI и для   API по одной фиче
Пример когда есть степы и для UI и для API по одной фиче

Например, сли для одной фичи (авторизация) есть шаги как для UI, так и для API, разделите их по типам тестов: создайте файлы со степами и в steps/e2e, и в steps/api, разместив соответствующее содержимое в каждом из них.

Пример  когда  по определеной фиче есть только UI или же только API степы
Пример когда по определеной фиче есть только UI или же только API степы

А если степы по определеной фиче можно отнести только к UI или API то и размещаем их только там, где им мест

  • Если все степы небольшой фичи можно уместить в один файл, не нужно создавать дополнительную подпапку. Кладите файл прямо в steps/api или steps/e2e и называйте в kebab-case с суффиксом -step.ts.

    Путь:/common/steps/e2e (api) /{feautureName}-step.ts

 Пример  когда не нужно разделение степов по подпапкам (фичам)
Пример когда не нужно разделение степов по подпапкам (фичам)

Например, степ авторизациии можно поместить в отдельный файл auth-step в /common/steps/e2e (api)/feature-step.ts

⚠️ В моей структуре и примерах - степы не описаны внутри класса, а экспортируются как отдельные методы или функции (если нужно вернуть что либо)

Как выглядят степы для e2e (UI) автотестов? 👇

Пример степа который используется в трех автотестах

Есть три кейса у которых повторяется последовательность шагов которые приводят к результату - как успешно созданный заказ:

Автотест 1: оформление заказа

Автотест -2: редактирование уже оформленного заказа

Автотест-3: отмена заказа

Поскольку последовательность 'создать заказ' уже описана шагами, в тесте на редактирование/отмену -> правильнее вынести последовательность шагов в степы и уже переиспользовать созданный степ в 3 автотестах.

Ниже пример содержимого order.step.ts

/ //common/steps/e2e/order-step.ts

import { ProductPage } from "common/pages/ProductPage";
import { BasketPage } from "common/pages/BasketPage";
import { OrderPage } from "common/pages/OrderPage";


export async function createOrderViaUI(
    page: Page,
    productId: number,
    deliveryType: string,
    address: string,
    expectedOrderStatus: string,
    paymentType: string,
    card?: CardData
) {
    const productPage = new ProductPage(page);
    const basketPage  = new BasketPage(page);
    const orderPage   = new OrderPage(page);

    // 1) Товар → корзина
    await page.goto(`/product/${productId}`);
    await productPage.addProductToBasket();

    await basketPage.goToBasket();
    await basketPage.ensureProductIsInBasket(productId);
    await basketPage.buyProductByClick(productId);

    // 2) Оформление
    await orderPage.ensureOrderDetailsAreShownFor(productId);

    const orderId = await orderPage.getOrderId();
    expect(orderId, 'orderId должен вернуться на странице оформления').toBeTruthy();

    await orderPage.selectDelivery(deliveryType);
    await orderPage.fillAddressToDeliver(address);
    await orderPage.selectPaymentType(paymentType);

    // 3) Оплата
    if (paymentType === 'card') {
        if (!card || !card.cardNumber || !card.cardHolder || !card.cardExpire || !card.cardCvv) {
            throw new Error(
                'Для оплаты картой необходимо передать card: { cardNumber, cardHolder, cardExpire, cardCvv }'
            );
        }
        const cardComp = new CardComponent(page);
        await cardComp.fillCardNumber(card.cardNumber);
        await cardComp.fillCardHolder(card.cardHolder);
        await cardComp.fillCardExpireDate(card.cardExpire);
        await cardComp.fillCardCvv(card.cardCvv);
        await cardComp.acceptPayment();
    } else {
        await orderPage.payWithAlternative(paymentType);
    }

    // 4) Проверки и возврат результата
    await orderPage.ensureOrderIsPaid(orderId);

    const status = await orderPage.getOrderStatus(orderId);
   
    await expect(status).toMatch(expectedOrderStatus);

    return { orderId, status };
}

Далее теперь посмотрим как вызывается данная функция-степ в 3 -х автотестах

Примеры переиспользования степа из order-step.ts в трех автотестах

Далее теперь посмотрим как вызывается данная функция-степ в 3 -х автотестах

Пример автотест на создание заказа:

// /tests/e2e/EditOrder.spec.ts
import { test } from '@playwright/test';
import { createOrderViaUI } from '../../common/steps/e2e/order-step';

test('Успешное создание заказа', async ({ page }) => {

const orderPage = new OrderPage(page)
  
// 1) создаём заказ (переиспользуем шаг)
  const { orderId } = await createOrderViaUI(
    page,
    123,                       // productId
    'courier',                 // начальный способ доставки
    'Almaty, Main 1, 050000',  // начальный адрес
    'card',                    // оплата картой (для "оплаченного/отправленного"   статуса)
    {
      cardNumber: CARD_NUMBER.visa,
      cardHolder: CARD_NUMBER.holder,
      cardExpire: CARD_NUMBER.expireDate,
      cardCvv: CARD_NUMBER.cvv,
    }
  );
});

Пример автотеста на редактирование заказа:

// /tests/e2e/EditOrder.spec.ts
import { test } from '@playwright/test';
import { createOrderViaUI } from '../../common/steps/e2e/order-step';
import { 
  editOrderDelivery,
  editDeliveryAddress,
  assertOrderDeliveryChanged,
  assertDeliveryAddressChanged
} from '../../common/pages/OrderPage.ts';

test('Редактирование отправленного заказа.
     Изменение способа и адреса доставки', async ({ page }) => {

const orderPage = new OrderPage(page)
  
// 1) создаём заказ (переиспользуем шаг)
  const { orderId } = await createOrderViaUI(
    page,
    123,                       // productId
    'courier',                 // начальный способ доставки
    'Almaty, Main 1, 050000',  // начальный адрес
    'card',                    // оплата картой (для "оплаченного/отправленного" статуса)
    {
      cardNumber: CARD_NUMBER.visa,
      cardHolder: CARD_NUMBER.holder,
      cardExpire: CARD_NUMBER.expireDate,
      cardCvv: CARD_NUMBER.cvv,
    }
  );

  // 2) редактируем способ и адрес доставки
  const newDeliveryType = DELIVERY_TYPES.pickup
  const newAddress = 'Almaty, Pickup Point #3'
  
  await orderPage.editOrderDelivery(orderId, newDeliveryType);
  await orderPage.editDeliveryAddress(orderId, newAddress);

  // 3) убедиться что реально способ доставки и адрес изменен

  await orderPage.assertOrderDeliveryChanged(newDeliveryType)
  await orderPage.assertDeliveryAddressChanged(newAddress)
});

Пример автотеста на отмену заказа:

// /tests/e2e/EditOrder.spec.ts
import { test } from '@playwright/test';
import { createOrderViaUI } from '../../common/steps/e2e/order-step';

test('Отмена заказа', async ({ page }) => {

const orderPage = new OrderPage(page)
  
// 1) создаём заказ (переиспользуем шаг)
  const { orderId } = await createOrderViaUI(
    page,
    123,                       // productId
    'courier',                 // начальный способ доставки
    'Almaty, Main 1, 050000',  // начальный адрес
    'card',                    // оплата картой (для "оплаченного/отправленного"   статуса)
    {
      cardNumber: CARD_NUMBER.visa,
      cardHolder: CARD_NUMBER.holder,
      cardExpire: CARD_NUMBER.expireDate,
      cardCvv: CARD_NUMBER.cvv,
    }
  );

// 2) Отменяем заказ
   orderPage.cancelOrder(orderId)


 // 3) Убедиться что заказ отменен
   .....
   
});

Как выглядят степы для API автотестов? 👇

Пример кейса

Пример: Кейс 1: успешный вход в систему с паролем и имейлом && Кейс -2: Успешное востановление пароля.

Например, в API-тесте выполняются шаги, где 1-2 шаг повторяются для 1 и 2 кейса:

1) ввод логина и пароля и отправа кредов для проверки верны ли они

2) если верны то получен ответ для авторизованного пользователя, если нет то возращается ответ что креды (имейл или пароль неверны)

3) следующий шаг: это востановление пароля с помощью имейла или номера телефона

4) Отправляется второй запрос на отправку кода для сброса пароля на имейл или в смс, где в queryParametrers будет значение либо номера телефона либо имейла

5) далее отправляется третий запрос, где в queryParametrers будет значение кода востановления

6) если третий запрос успешно выполнен - то нужно выполнить четвертый запрос на установку нового пароля, где в queryParametrers будет значение нового пароля

7) если четвертый запрос вернул успешный ответ -> то заново отправляется запрос на вход в систему но уже с новым паролем

Так вот, чтобы не дублировать последовательность шагов 1-2 и 7 для входа в систему, в обоих кейсах -> нужно запрос из 1, 2 ,7 шага просто вынести в отдельный степ но только для API , который при неуспешном ответе будет возращать код и текст ошибки то есть негативный риспонз, а при успешном логине ответ который можно получить если и имейл и пароль верны и пользователь авторизован

📌 Config

В этой части содержится инфа о видах отчетов и их отправка в месенджер, а также что за папка в в тесовом проекте screenshots

1) playwright.config - это конфигурационный файл, где задаются все глобальные параметры запуска тестов.

Что входит в конфигурационный файл?

Я не буду пересказывать то, что уже хорошо описано в официальной документации, а отмечу лишь несколько полезных опций конфига и практик, которые пригодились лично мне)))

Полезные ссылки из оффициальной документации о конфиге: Общая инфа по playwright.config, Полезные опции, TestProject - прогон автотестах на разных конфигурациях, Перезапуск упавших тестов, Отчеты после прогона автотестов

То что пригодилось лично мне👇:

  • Dependencies для "типо" глобальнной авторизации 'auth setup' :)

  • Опция testIgnore для указания директории автотестов, которые игнорируются для определенного project внктри массива projects с помощью testignore.

Пример игнорирования
 projects: [
    {
      name: 'chromium',
      testDir: './tests',
      testMatch: /.*\.spec\.ts/,
      testIgnore: '**/test-assets/**', //Игнорируем все что внутри test-assets
      fullyParallel: true,
      use: { browserName: 'chromium' },
    },
    {
      name: 'firefox',
      testDir: './tests',
      testMatch: /ExampleTestFour.*\.spec\.ts/,
      testIgnore: /.*(ExampleTestOne|ExampleTestTWO)\.spec\.ts/, //игнорируются 
      fullyParallel: false,
      use: { browserName: 'firefox' },
    },
  ],

//либо можно вынести файлы с тестами которые 
//нужно проигнорировать в определеном project/s
// в отдельный массив

const ignored = [
  /ExampleTestFour.*\.spec\.ts/,
  /ExampleTest.*\.spec\.ts/,
];

//Пример
 {
      name: 'firefox',
      testDir: './tests',
      testMatch: /ExampleTestFour.*\.spec\.ts/,
      testIgnore: ignored, //игнорируются 
      fullyParallel: false,
      use: { browserName: 'firefox' },
    },
  • Опция snapshotPathTemplate для указания пути где хранятся ожидаемые снапшоты используемые для скриншотных проверок (выявление разницы между фактической картинкой, сделаной во время прогона автотеста с ожидаемой), Пример, /__screenshots__{/projectName}/{testFilePath}/{arg}{ext}

  • Опция ignoreHTTPSErrors - для игнорирования ошибок SSL/TLS.

    Пример, use: {ignoreHTTPSErrors: true},

  • reporter - массив с используемыми видами отчетов:

Виды и пример

1) html - стандартный HTML-отчёт Playwright, с шагами где понятно что и за чем было, но при условии что в автотесте использовались test.step

2) json - используется для получения данных, которые потом можно отправлять, например, в Telegram или Slack-бот. Из JSON-отчёта легко извлечь: количество успешных, скипнутых, упавших и flaky-тестов, а также общее время прогона (попозже напишу более подробно об этом в отдельной статье :))

3) allure-playwright - наиболее информативный отчёт: включает шаги, вложения и таймлайн выполнения тестов.

reporter: [
  ['list'], 
  ['json', { outputFile: 'reports/test-results.json' }],
  ['html', { open: 'never' }]
],

📌 Screenshots (snapshots)

Это директория в которой лежат ожидаемые скриншоты, используемые для визуальных проверок.

Все скриншоты лежат в: ./screenshots(snapshots)/

Путь к ожидаемым скриншотам указывается в playwright конфиге как: /__screenshots__{/projectName}/{testFilePath}/testFileName/{arg}{ext}

  • projectName - название project в projects[ ] в playwright.config (например, desktop-chrome, safari, iphone 14, pixel 7, ipad и т д ).

  • {testFilePath} - путь от testDir до файла c тестом e2e/test-assets/...

  • {testFileName} - имя файла теста как каталог (например, Basket.spec.ts).

  • {arg} — заголовок из toHaveScreenshot('...') если не пустой, или название автотеста {testName}.

    Например,'Show five items in a basket' -> 'Show-five-items-in-a-basket'.

    Иногда в конце имени добавляется индекс скриншота в тесте, если кол-во скриншотов в тесте несколько.

    Например: Show-five-items-in-a-basket.pngShow-five-items-in-a-basket-1.png, Show-five-items-in-a-basket-2.png

  • {ext} — расширение файла (обычно .png)

Разберем пример👇:

Например, скриншотное сравнение есть в тесте на просмотр товаров в корзине.

Путь к этому тесту ./tests/e2e/Basket.spec.ts или ./tests/e2e/test-assets/Basket.spec.ts.

Название автотеста где есть визуальное сравнение toHaveScreenshot , например: 'Show five items in a basket'.

Ожидаемый скриншот будет храниться в:

  • Если файл автотеста не лежит в test-assets: /screenshots(snapshots)/desktop-chrome/e2e/Basket.spec.ts/Show-five-items-in-a-basket-1.png

  • Если файл автотеста находится внутри подпапки test-assets (которая игнорируется в некоторых projects в playwright.config), путь включает эту папку: ./screenshots(snapshots)/desktop-chrome/e2e/test-assets/Basket.spec.ts/Show-five-items-in-a-basket-1.png


📌 Глава 3. Ауетентификация в автотестах (спойлер другой статьи)

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

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

Чтобы не авторизовываться в каждом предусловии есть понятие как глобальная ауетенфикация. То есть, сперва выполняется авторизация всех используемых пользователей в автотестах и их авторизационные данные сохраняются в файлах json, и затем переиспользуются ( localStorage, куки и сессии).

Можно авторизовываться с исполльзованием API и UI. В оффициальной документации Playwright уйма описания и примеров - Ауетентификация

В данной статье, покажу не самый лучший и не топ, но простой способ авторизации сразу нескольких пользователей с разными ролями через UI, сохранение их авторизационных данных и их переиспользование в автотестах.

Ауетентификация (более одной роли). Этот способ удобен, если есть несколько ролей в end-to-end тестах, и можно переиспользовать одни и те же аккаунты во всех тестах.

⚠️ С чего начать?

Начать стоит с сохранения авторизационных данных в storageState отдельно для каждой роли, в рамках сессии.

Запись данных сессии в storageState (шаги и примеры):

После успешной авторизации все необходимые данные сессии (cookies, localStorage, sessionStorage, токены и другие параметры состояния) сохраняются в storageState.

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

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

Шаг-1:

Предположим, для автотестов используем 6 пользователей, разделенные по 3 ролям, где 3 пользователя - это пользователи с простой ролью, например как покупатели, 2 как продавцы (различие в доступном функционале), 1 как админ, которыму доступно все.

У каждой из трёх ролей есть свои права и свойства, которые возвращаются после входа пользователя в систему. Все данные сессии по каждому пользователю - нужно сохранить в JSON-файл. Так как пользователей 6, то и количество JSON файлов будет равно 6.

Пути к JSON-файлам, в которые сначала записываются данные сессии по каждому пользователю, а затем переиспользуются в автотестах через storageState, необходимо также объявить в виде константных переменных.

Для этого создадим auth-state-constants.ts в папке ./constants/auth/, в котором объявляем объект AUTH_ROLE_STATES, где ключи соответствуют ролям, а значения содержат пути к auth.json файлам.

// ./constants/auth/auth-state-constants.ts

/**
* Пути к auth.json файлам, используемые для storageSate
*/
export const AUTH_ROLE_STATES = {
  customer: {
    first: './.auth/first-customer-auth.json',
    second: './.auth/second-customer-auth.json',
    third: './.auth/third-customer-auth.json',
  },
  professional: {
    first: './.auth/first-prof-auth.json',
    second: './.auth/second-prof-auth.json',
  },
  admin: {
    main: './.auth/admin-auth.json',
  },
}

Шаг-2:

Также вынесем все email-ы и пароли тестовых пользователей в отдельный файл с константами в этой же папке ./constants/auth/, а файл назовем как user-creds-constants.ts

// ./constants/auth/user-creds-constants.ts 


import { AUTH_ROLE_STATES } from './auth-state-constants';

/**
*  Данные пользователей для авторизации
*/
export const USER_CREDENTIALS = {
  firstCustomer: {
    email: 'test-customer@example.com',
    password: 'superSecret123',
    jsonPath: AUTH_ROLE_STATES.customer.first,
  },

  ......
  firstProffi: {
    email: 'test-proffi@example.com',
    password: 'superSecret123',
    jsonPath: AUTH_ROLE_STATES.professional.first,
  },  
  ....
  admin: {
    email: 'test-admin@example.com',
    password: 'superSecret123',
    jsonPath: AUTH_ROLE_STATES.admin.main,
  }
} 

Шаг-3:

А вот только теперь, нужно заполнить логику записи авторизационных данных в JSON файлы по каждому из 6 пользователей.

Как на примере из оффициальной документации, сперва создаем файл globalAuth.setup.ts. Этот setup используется для установки зависимости или глобального предусловия. Например, авторизация всех пользователей с разными ролями перед запуском автотестов.

// playwrightTests/globalAuth.setup.ts

import { test as setup, expect } from '@playwright/test';


async function loginAndSave(
   page, email:
   string,
   password: string, 
   jsonPath: string
) {
  const loginPage = new LoginPage(page)

  // Авторизация
  await loginPage.goto(loginPage.url)
  await loginPage.login(email, password)
  await expect(page).toHaveURL(...//главная после успешного логина)
  

  // Сохраняем storage state для каждой роли в отдельный файл
  await page.context().storageState({ path: jsonPath })
  await context.close()
}

setup('customer 1', async({page}) => {
   loginAndSave(
      page,
      USER_CREDENTIALS.firstCustomer.email, 
      USER_CREDENTIALS.firstCustomer.password
  )
})


setup('proffessional 1', async({page}) => {
   loginAndSave(
      page,
      USER_CREDENTIALS.firstProffi.email, 
      USER_CREDENTIALS.firstProffi.password
  )
})

setup('admin', async({page}) => {
   loginAndSave(
      page,
      USER_CREDENTIALS.admin.email, 
      USER_CREDENTIALS.admin.password
  )
})

После успешной авторизации всех пользователей в проекте playwrightTest должна автоматически создаваться директория .auth, внутри которой будет находиться 6 JSON-файлов с сохранёнными сессиями (storage state) для каждого пользователя.

Ниже показана структура проекта, в которой будут сохраняться JSON-файлы сессий:

Иерархия .auth
Иерархия .auth

Если открыть любой JSON-файл, то внутри будет сохранено всё состояние сессии для конкретного пользователя.

Шаг-4:

Чтобы запустить выполнение содержимого файла globalAuth.setup.ts (Setup), в playwright.config.ts -> нужно добавить отдельный проект внутри projects. Этот проект (setup auth) должен быть указан первым, чтобы он выполнялся до запуска всех остальных автотестов.

Остальные проекты, в которых выполняются автотесты, уже используют указанный storageState и таким образом переиспользуют сохранённые данные сессии

Пример конфига, где указан setup auth и использование сохранённой сессии:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      // setup auth выполняется первым и создаёт файлы в .auth/
      name: 'setup auth',
      testMatch: /.*\.setup\.ts/,
      testDir: './tests',
      fullyParallel: true,
    },
    {
       name: 'chrome', 
       use: { storageState: STORAGE_STATE_JSON_PATH },
    }, 
    {
      name: 'firefox',
      testDir: './tests',
      fullyParallel: true,
      dependencies: ['setup']
    }
  ],
});

⚠️ Как регулировать storageState?

Теперь необходимо указать, какой storageState использовать для разных автотестов. Для этого есть три варианта:

1) Указать storageState в playwright.config -> projects ->project -> use -> storageState

В некоторых случаях, прежде чем запустить project, необходимо сначала выполнить setup auth. Для этого указывают зависимость project от setup auth с помощью параметра dependencies. Последовательность запуска если есть setup

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

  projects: [
     {
       name: 'chrome', 
       use: { storageState: STORAGE_STATE_JSON_PATH },
     }, 
    {
      name: 'firefox',
      testDir: './tests',
      fullyParallel: true,
      dependencies: ['setup']
    }
  ]

2) Указать в конкретном файле с автотестами с расширением .spec.ts.

Перед тестом нужно добавить test.use({ storageState: STORAGE_STATE_JSON_PATH })

Этот подход позволяет удобно разграничивать и быстро менять storageState для группы тестов или всего файла с автотестами.

Это особенно полезно, когда один проект в конфиге запускает сразу несколько файлов с тестами, и не всегда всем им нужен один и тот же storageState.

Также при использовании этого подхода можно извлечь токен из JSON-файла storageState и переиспользовать его в автотестах внутри .spec.ts.

Пример, использования storageState в файле, где лежат автотесты
import { test } from '@playwright/test';

import { AUTH_ROLE_STATES } from '../authRoleStates';


// Указываем storageState для всех тестов в этом файле
test.use({ storageState: AUTH_ROLE_STATES.admin.main });

test('admin test', async ({ page }) => {
   // page is authenticated as a user
 });


test.describe(() => {
  // Указываем storageState один раз для всего test suite

  test.use({ storageState: AUTH_ROLE_STATES.customer.first });

  test('user test', async ({ page }) => {
    // page is authenticated as a user
  });
});

3) Бывает что, в некоторых автотестах нужно наоборот отключить использование storageState. Тогда просто указываем перед автотестами или внутри suite:

test.use({ storageState: { cookies: [], origins: [] } });

//автотест в spec.ts

test.describe(() => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test('api test ', async ({ page }) => {

      getTokenFromStorage(AUTH_ROLE_STATES.customer.first)
     
  });
});

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

 projects: [
    {
      // setup auth выполняется первым и создаёт файлы в .auth/
      name: 'setup auth',
      testMatch: /.*\.setup\.ts/,
      testDir: './tests',
      fullyParallel: true,
    }
    {
      
      name: 'chrome',
      testIgnore: /.*\.Smoke.spec\.ts/,
      testDir: './tests',
      fullyParallel: true,
      dependencies: ['setup']
    }
  ],

⚠️ Получение значений из JSON

Также при использовании этого подхода можно извлечь токен из JSON-файла storageState и переиспользовать его в автотестах внутри .spec.ts.

Пример получения токена из JSON файла:

Пример получения токена из json

Добавим функцию которая возращает токен из json в helpers/auth-helper.ts

//helpers/auth-helper.ts

export function getTokenFromStorage(storageName: string): string | undefined {
  // Загружаем storageState из файла
  const storageState = JSON.parse(
    fs.readFileSync(storageName, 'utf-8')
  );

  // Проверяем, что в origins есть localStorage
  if (!storageState?.origins?.[0]?.localStorage) {
    return undefined;
  }

  // Допустим, токен хранится в localStorage
  const token = storageState.origins[0].localStorage.find(
    (item: any) => item.name === 'auth_token'
  )?.value;

  return token;
}

Вызовем getTokenFromStorage в автотесте:

//автотест в spec.ts

test.describe(() => {
  test('api test ', async ({ page }) => {

      getTokenFromStorage(AUTH_ROLE_STATES.customer.first)
     
  });
});

Заключение

В этой статье я сосредоточилась в основном на вспомогательной части для автотестов, приводя примеры того, что стоит использовать, а чего лучше избегать. Полноценная глава, посвящённая самим тестам, будет вынесена в отдельный материал.

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

Теги:
Хабы:
+1
Комментарии0

Публикации

Ближайшие события