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

И такс, наконец-то я добралась до части, где больше не нужно писать про вспомогательную часть для автотестов ?.

Не думайте что хаос безобиден, он опасен особенно в тестах
Не думайте что хаос безобиден, он опасен особенно в тестах

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

Повторюсь еще раз что рефакторинг важен - проще вовремя "прибраться", чем потом устраивать генеральную уборку. Отмазки вроде "доделаю потом", "поживём - увидим", "подправим позже" - не работают!!!! (Но это, конечно, лишь моё мнение ?)

В этой статье будут расмотрены следующие темы:

  1. Правила хорошего автотеста

  2. Приоритет автоматизации сценариев

  3. Наименование файлов .spec

  4. Группировка файлов .spec

  5. Использование хуков beforeEach / afterEach / describe

  6. Опция test.step

  7. Использование тегов в автотестах

  8. Параметризированные тесты

  9. Smoke tests

  10. Запуск smoke тестов

  11. E2E (end-to-end) тесты

  12. Codegen - как помощник в написании теста

  13. Логирование в e2e (test.info)

  14. Разбор e2e автотеста

  15. Скриншотные проверки

  16. Настройка запуска e2e автотестов в конфиге

  17. Ожидания (таймауты) в e2e

  18. API-тесты

  19. Схемы ответов (Schemas)

  20. apiRoutes (эндпоинты)

  21. API-хелпер

  22. Step pattern для API-тестов

Теперь можно перейти к перечислению правил хороших автотестов.

Правила хорошего автотеста:

  • должен:

  1. проверять один кейс.

    Один тест = один сценарий. Не объединяйте множество разных проверок в один тест.

  2. собираться из вспомогательных частей.

    Используйте PageObject (locators, components, helpers), constants, helpers, step pattern, snapshots, components.

  3. хранить значения в constants, которое используется более 1 раза.

    Храните захардкоженые значение в константах а не в самом месте где лежит тест.

  4. иметь понятное название.

    Название теста должно сразу объяснять, что именно проверяется.

  5. быть легко читаемым и состоять из простой логики.

    Чем проще написан тест, тем он надёжнее и понятнее. То есть, название методов и функций должны чётко отражать их назначение, а содержимое внутри теста или методов используемых в тесте не должно быть "изобретением велосипеда". Повторяющиеся блоки по смыслу оборачивайте в test.step.

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

    Например: userId, orderId, experimentBucket, priceBefore/priceAfter.

    Логирование удобно делать через test.step('проверка ключевого значения', …) с ассертами вида expect(keyValue, { message: keyValue }).toBeTruthy(), которые проверяют, что значение не пустое / не null / не 0, либо через testInfo.attach

  • не должен:

  1. зависеть от других тестов.
    Тест №2 не должен проходить только при условии, что успешно прошёл тест №1. Каждый тест должен быть независимым, даже если оба используют один и тот же ресурс.

  2. дублировать код.
    Если повторяющийся код встречается в разных местах - выносите его в common(support). Например, в helpers выносится логика работы с данными (получение, преобразование, трансформация и т. д.), в steps повторяющиеся последовательности шагов или вызовы методов из PageObject.

  3. дублировать шаги, где различие только во входных или выходных данных.

    Если логика одинакова, используйте параметризованные тесты (parametrize tests) вместо копирования кода. Parametirize tests

  4. содержать искусственных таймаутов (sleep, waitForTimeout), которые останавливают выполнение. Вместо этого нужно использовать явные ожидания появления следующего элемента или наступления события.

  • следует:

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


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

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

    Если же функционал нестабилен или дешевле проверить вручную -> такие кейсы оставляйте для ручного тестирования.

  2. Сначала автоматизируйте позитивные сценарии.
    Они обеспечивают базовую проверку основной функциональности.

  3. Затем переходите к негативным сценариям.
    Это помогает убедиться, что система корректно обрабатывает поведение когда что - то пошло не так!

  4. Если необходимо автоматизировать кейс на клиенте, но бэкенд нестабилен и эндпоинт возвращает разные ответы, влияющие на поведение клиента, используйте замоканные ответы для каждого возможного варианта поведения.
    При этом, если есть возможность, обязательно добавьте отдельный API-тест, который проверяет этот же эндпоинт на код и общую структуру ответа соответствующую этому коду (без детальной проверки).


Наименование файлов c расширением .spec:

  • должно быть в PascalCase, где имя файла должно описывать суть тестируемой функциональности
    Например: ProfileSettings.spec.ts  - тесты проверяющие настройки в профиле пользователя, ForgotPassword.spec.ts - тесты на востановление пароля.

Группировка файлов c расширением .spec:

  • Если по одной фиче накапливается большое количество тестов, их рекомендуется разделять на несколько файлов и сгруппировать в отдельной папке. Название папки должно отражать данную фичу в целом.
    Например, тесты ProfileView.spec.ts и ProfileEdit.spec.ts должны находиться в общей папке tests/e2e/profile, а фича Authorization может содержать файлы ForgotPassword.spec.ts и SignUp.spec.ts, где путь tests/e2e/Authorization/ или tests/api/AuthCheck/


❗Важно: Использование beforeeEach / afterEach /describe hooks

Предусловия (beforeEach), постусловия (afterEach) и группировка тестов (describe) - это базовые механизмы Playwright Test, применимые ко всем видам автотестов (API, e2e, smoke)

Предусловие перед тестом:

BeforeEach выполняется перед каждым тестом.

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

Для того чтобы не повторять одни и те же шаги в начале каждого теста в пределах одного spec.ts нужно использовать beforeEach

Пример использования beforeEach
import { test, expect } from '@playwright/test';

let createdProducts = {}

test.beforeEach(async ({ page, request }) => {
  // предположим, что API вернёт список 
  // товаров где категория = электроника

  createdProducts = await request.post('/api/products/create', {
      data: { count: 5, category: 'electronics' },
  });
  const createdProducts = await resp.json();
 
  // 2. Переходим на страницу категории "электроника"
  await page.goto('/products/electronics');

  // 3. Проверяем, что на странице отобразились = 5 товаров
  const items = page.locator('[data-testid="product-item"]');
  const count = await items.count();

  expect(count).toBeGreaterThanOrEqual(5);

  // 4. Проверяем, что каждая карточка имеет атрибут с id товара из API

  for (const product of createdProducts) {
     const item = page.locator([data-testid="product-item"][data-id="${product.id}"]);
    await expect(item).toBeVisible();
  }
});

Как видно на примере, в spec.ts в beforeEach содержатся запросы к API, и действия с локаторами. Для прототипа нормально, но плохо масштабируется. Потому что, например, получение списка товаров понадобится в других тестах (создание, удаление, фильтры и т.д.), то придётся копировать куски кода.

Чтобы не копировать одно и тоже - вынесем все что связано с получением товаров по определеной категории в хелперы или в pageObject (см. пример)

Пример отрефаченного beforeEach
//common/helpers/products-helper.ts
import { APIRequestContext} from '@playwright/test';
import { PRODUCT_CREATE} from 'playwrightTests/common/apiRoutes';

/**
 * Создаёт n товаров в определеной категории 
 */
export async function createManyProductsByCategory(
  request: APIRequestContext,
  categoryName: string,
  totalCount: number
) {
  try {
    const resp = await request.post(PRODUCT_CREATE, {
      data: { count: totalCount, category: categoryName },
    });

    if (!resp.ok()) {
      throw new Error(
        'Ошибка при создании товаров. Status: ' + resp.status()
      );
    }

    const data = await resp.json();

    if (!Array.isArray(data)) {
      throw new Error('API вернуло НЕ массив');
    }

    return data;

  } catch (err) {
    throw err
  }

}

Далее, можно также перенести в PageObject или просто обернуть в test.step следующую часть предусловия

Было

----------------------

// 3. Проверяем, что на странице отобразились > 5 товаров

  const items = page.locator('[data-testid="product-item"]');
  const count = await items.count();

  expect(count).toBeGreaterThan(limit);

---------------------

Стало: Первый вариант - обернуть в step

test.step('Проверяем, что на странице отобразились > 5 товаров', 
  async () => {
   const item = page.getByTestId(PRODUCT_ITEM);
   const count = await item.count();

   expect(count).toBeGreaterOrEqual(limit);
})

---------------------

Стало: Второй вариант - вынести в PageObject (лучше)

// common/pages/ProductListPage.ts

async checkProductsCount(limit: number) {
  const count = await this.item.count();

  await expect(count).toBeGreaterThanOrEqual(limit);
}

Последнее что можно подправить в предусловии, то это наличие атрибута id товара в карточке товара

Было
-----------------------
// 4. Проверяем, что каждая карточка имеет атрибут с id товара 

 for (const product of createdProducts) {
    const item = page.locator([data-testid="product-item"][data-id="${product.id}"]);
    await expect(item).toBeVisible(); 
  }



Стало 

----------------

Первый вариант - обернуть в step

test.step('Проверяем, что каждая карточка имеет атрибут с id товара', async () => {

  for (const product of createdProducts) {

    const item = page.getByTestId(PRODUCT_ITEM).locator([data-id="${product.id}"]);

    await expect(item).toBeVisible();

  }

});

-------------------

Второй вариант - вынести в PageObject (лучше)

// common/locators/product-locators.ts

export function getProductCardById(id: number): string {

  return [data-testid="${PRODUCT_ITEM}"][data-id="${id}"];

}

// common/pages/ProductListPage.ts

async verifyProductCardsVisible(products: { id: number }[]) {
  for (const product of products) {

   const item = this.page.locator(getProductCardById(product.id));
   await expect(item).toBeVisible();
  }
}

Финальный вид предусловия после небольшого рефакторинга будет выглядеть так:

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

import { createManyProductsByCategory} from '../../common/helpers/products-helper';

import { ProductListPage } from '../../pages/ProductListPage';

import { CATEGORIE_NAMES, PRODUCT_ITEM } from '../../common/constants/....';

// Указываем storageState для всех тестов в этом файле

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

let createdProducts: {} 

test.beforeEach(async ({ page, request }) => {
  productsPage    = new ProductListPage(page);
  createdProducts = await createManyProductsByCategory(
    request,
    CATEGORIE_NAMES.electronics,
    LIMIT
  );

  test.step('Переходим на страницу электроники', async () => {
     await page.goto('/products?category=' + CATEGORIE_NAMES.electronics);

  })

  await productsPage.checkProductsCount(LIMIT);

  await productsPage.verifyProductCardsVisible(createdProducts);

});
  

Постусловие после теста:

AfterEach выполняется после каждого теста.

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

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

Для таких действий используют постусловия, то есть хук afterEach (или afterAll, если данные общие на весь набор тестов).

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

Предположим, что в файл с расширением .spec.ts называется ProductListActions. В нем, например 3 теста для которых нужно более 1 товара на странице.

Названия тестов для примера следующие:

// playwrightTests/tests/e2e/ProductListActions.spec.ts

test('Добавлении товаров в сравнение и 

   отображение их в списке сравнения', async () => {

  ...

});

test('Добавлении более одного товара в корзину', async () => {

  ...

});

test('Добавлении несколько товаров в избранное', async () => {

  ...

});

После прохождения каждого теста, хорошо бы сразу удалять сгенеренные в предусловии товары в разделе электроники. Удалять товары нужно в постусловии - afterEach.

В рамках примера, воспользуемся методом удаления товаров через api запрос, который удаляет товар по его id.

//common/helpers/products-helper.ts

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

import { PRODUCT_CREATE} from 'playwrightTests/common/apiRoutes';

/**

 * Удаляет товар по его Id

 */

export async function deleteProductById(

   request: APIRequestContext, 

   id: number

) {
  const resp = await request.delete(API_ENDPOINTS.PRODUCTS, {

      data: { productId: id },

   });

   expect(resp.status()).toBe(200);
});

Теперь нужно вызвать метод удаление товара по его id в постусловии:

// playwrightTests/tests/e2e/ProductListActions.spec.ts

/*
 * Подщищаем созданые товары после теста
*/
test.afterEach(async ({ request }) => {

  for (const product of createdProducts) {

    await deleteProductById(request, product.id);

  }

});

Хук test.describe (группировка тестов):

Хук test.describe позволяет объединять несколько тестов в логическую группу внутри одного spec.ts.

Позволяет задавать свои beforeEach/afterEach для отдельных групп. Это удобно, когда нужно отделить один набор тестов от других. То есть, если часть тестов требует отличное предусловие или постусловия, чем в первой группе -> их можно вынести в отдельный describe, не создавая новый файл.

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

Допустим, в файле есть 5 тестов: для 3 из них подходит общее предусловие/постусловие, а для 2 тестов - нет, потому что, например, во втором случае нужно сгенерировать не 5 товаров на странице, а 20, причём в категории "товары для животных" с целью "проверить сортировку по цене или популярности".

Вместо того чтобы выносить эти 2 теста в отдельный файл и придумывать для него имя, можно сгруппировать тесты прямо в том же файле - где каждая группа тестов будет иметь свои собственные предусловия и постусловия внутри одного spec.ts

Пример разделения тетов на группы - test.describe

Пример разделения тестов на группы - test.describe
test.describe('Первая группа', () => {
  // Указываем storageState для всех тестов в этой группе тестов
  test.use({ storageState: AUTH_ROLE_STATES.customer.first });

  let createdProducts: {};
  const LIMIT = 5;

  test.beforeEach(async ({ page, request }) => {
    const productsPage = new ProductListPage(page);

    createdProducts = await createManyProductsByCategory(
      request,
      CATEGORIE_NAMES.electronics,
      LIMIT
    );

    await test.step('Переходим на страницу электроники', async () => {
      await page.goto('/products?category=' + CATEGORIE_NAMES.electronics);
    });

    await productsPage.checkProductsCount(LIMIT);
    await productsPage.verifyProductCardsVisible(createdProducts);
  });

  test('Добавление товаров в сравнение и отображение их в списке сравнения', async () => {
    // ...
  });

  test('Тест №2', async () => {
    // ...
  });

  test('Тест №3', async () => {
    // ...
  });

  test.afterEach(async ({ request }) => {
    for (const product of createdProducts) {
      await deleteProductById(request, product.id);
    }
  });
});

В этом же .spec.ts добавляем вторую группу тестов

// Вторая группа
test.describe('Вторая группа', () => {
  // Указываем storageState для всех тестов в этой группе
  // Уже авторизован другой пользователь
  test.use({ storageState: AUTH_ROLE_STATES.customer.second });

  let createdProducts: {};
  const LIMIT = 20;

  test.beforeEach(async ({ page, request }) => {
    const productsPage = new ProductListPage(page);

    createdProducts = await createManyProductsByCategory(
      request,
      CATEGORIE_NAMES.animalCare,
      LIMIT
    );

    await test.step('Переходим на страницу товаров для животных', async () => {
      await page.goto('/products?category=' + CATEGORIE_NAMES.animalCare);
    });

    await productsPage.checkProductsCount(LIMIT);
    await productsPage.verifyProductCardsVisible(createdProducts);
  });

  test('Сортировка товаров по цене', async () => {
    // ...
  });

  test('Сортировка товаров по популярности', async () => {
    // ...
  });

  test.afterEach(async ({ request }) => {
    for (const product of createdProducts) {
      await deleteProductById(request, product.id);
    }
  });
});

Использование опции в автотестах - test.step:

В Playwright есть опция test.step, которая позволяет объединять несколько действий в один шаг. Это делает код визуально более читаемым, а в отчёте HTML или Trace Viewer шаги отображаются структурировано и понятнее.

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

Например, в тесте нет степов, а коменты внутри теста

Пример без test.step
test('Сортировка товаров по цене', async ({ page, request }) => {
  const products = new ProductListPage(page);

  // проверяем сортировку (такие коменты лучше убрать)
  await sortProductsByPrice(request, 'desc');
  await products.open('/products');
  await products.sortByPriceDesc();

  // Проверка: товары должны быть отсортированы по убыванию цены 
  const prices = await page.locator('[data-testid="product-price"]').allTextContents();
  await products.toBeSortedInDescendingOrder();
});

Без test.step отчёт лишён смысловой структуры, то есть последовательность действий видна, но этапы (подготовка, сценарий, проверки) не отделены.

Сортировка товаров по цене

- GET /api/products?sort=price_desc

- page.goto('/products')

- click [data-testid="sort-by-price"]

- locator('[data-testid="product-price"]').allTextContents()

- expect([...]).toEqual([...])

Теперь обернем, логически связанные части в test.step:

Пример с test.step
test('Сортировка товаров по цене', async ({ page, request }) => {
  const products = new ProductListPage(page);

  await test.step('Подготовить данные через API', async () => {
    await sortProductsByPrice(request, 'desc');
  });

  await test.step('Открыть страницу и включить сортировку в UI', async () => {
    await products.open('/products');
    await products.sortByPriceDesc();
  });

  await test.step('Проверить, что товары отсортированы по убыванию цены', async () =>  {
    const prices = await page.getByTestId(PRODUCT_PRICE).allTextContents();
    
    await products.toBeSortedInDescendingOrder();
  });
});

Отчет в котором есть test.step выглядит более читабильнее:

Подготовить данные через API

   - GET /api/products?sort=price_desc

Открыть страницу и включить сортировку

   - page.goto('/products')

   - click [data-testid="sort-by-price"]

Проверить порядок цен

   - allTextContents()

   - expect(...)

Итог: использование test.step делает код теста и отчёт структурированными:

  • каждый шаг виден отдельно;

  • последовательность действий легко проследить;

  • проверки сгруппированы по смыслу;

  • при падении теста быстрее найти причину.

Использование тегов в автотесте:

Теги позволяют:

  • отмечать отдельные тесты.

    Например, в название теста просто добавляем тег @screenshot - то есть, это означает что в данном тесте есть визуальная проверка, или @smoke - означает тест выполняет смоук проверку, ну или @flaky - то есть кейс то успешен то нет

  • запускать конкретные тесты, указывая название нужного тега в команде запуска.

    Например, "npx playwright test --grep @screenshot" запустит все тесты где есть такой тег. А еще удобно обновлять скриншоты у тестов в которых есть визуальная проверка - "npx playwright test --grep @screenshot --update-snapshots"

  • видеть теги в названии кейсов во всех отчётах, в том числе в отчётах TMS (например, [@screenshot] Востановление пароля - то есть в отчете увидите тег и по названию понятно что в этом тесте есть визуальная проверка)

Пример тегов в автотестах (много примеров также в оффициальной доке Tag tests)

Пример использования тегов в автотестах
test.describe('Сортировка товаров', {
  tag: '@sortProducts', 
}, () => {
  test('Сортирвка товаров по цене (убыванию)', async ({ page }) => {
    // ...
  });

  test('Сортирвка товаров по рейтингу', {
    tag: ['@screenshot', '@slow'],
  }, async ({ page }) => {
    // ...
  });
  • tag: '@sortProducts' - это тег на всю группу тестов, запустить все тесты на сортировку можно указав тег в команде запуска: npx playwright test --grep @sortProducts

  • tag: ['@screenshot', '@slow'] - отмечает что тест содержит визуальную проверку и также медленый

Также, например нужно обновить все ожидаемые скриншоты в автотестах на сортировку в которых есть визуальные проверки то тогда запускаем команду:

npx playwright test --grep "@sortProducts.*@screenshots" --update-snapshots

Параметризированные тесты в Playwright (или тесты с дата провайдером):

Когда для нескольких тест-кейсов логика автоматизированного теста одинакова и отличается только входными данными, такие тесты приводят к параметризированным(Пример из офф доки). В этом случае отличающиеся значения параметров выносятся в массив, а тест выполняется для каждого набора данных.

Для примера, используются кейсы для проверки максимального и минимального колличества одного и тог же товара в корзине:

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

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

Пример параметризированных тестов
/**
 * Data provider для тестов на отображение счетчика 
 * одного и того же в корзине
 **/
const BASKET_SCENARIOS = [
    { testName: 'минимального количества',  count: 1,  expected: 1 },
    { testName: 'максимального количества',  count: 5,  expected: 5 },
];

for (const scenario of BASKET_SCENARIOS) {
    test(`Добавление ${scenario.testName} (${scenario.count}) товаров в корзину`, async ({ page, request }) =>{
        const productPage = new ProductPage(page);

        const product = await test.step('Выбор случайного товара', async () => {
            return await getRandomProduct(request);
        });
        const productId = product.id;

        await test.step('Открыть страницу товара', async () => {
            await page.goto(`/product/${productId}`);
        });

        await test.step('Добавить товар/ы в корзину', async () => {
            for (let i = 0; i < scenario.count; i++) {
                await productPage.addProductToBasket(i);
            }
        });

        await test.step('Проверить корзину', async () => {
            await page.goto('/cart');
            await expect(page.locator(PRODUCT_ID_COUNT)).toHaveCount(scenario.expected);
        });
    });
}

Немного о видах автотестов:

⚠️ В Playwright стандартный формат файлов с тестами - .spec.ts, при этом каждый файл должен содержать тесты, относящиеся к одной конкретной фиче или функциональности.

 Путь к тестам: playwrightTests/tests/

⚠️ В папке tests содержатся три директории: smoke, e2e, api.


Котики не курят тесты!  Котики их пишут и запускают!
Котики не курят тесты! Котики их пишут и запускают!

1) Smoke tests - это самая первая часть тестов, которая выполняется до запуска всего остального. Они запускаются первыми, чтобы убедиться, что приложение открывается и что на тестовом окружении всё необходимое для автотестов работает корректно.

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

В Smoke тестах можно миксовать виды автотестов как UI так и API. Например, проверка что страница возращает 200 код или пользователь существует, соединение с бд и типо того.

Примеры Health Check
// Smoke tests ./tests/smoke/HealthCheck 

test('is host alive', async ({ page }) => {

   const response = await page.goto('https://example.com/');

   expect(response?.status()).toBe(200);
   await expect(page).toHaveTitle(/Example Domain/);
});


test('Пользователь существует', async ({ request }) => {

  const userCheck = await request.get('/api/users/testUser/email=testUser@email.com');

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

  const body = await userCheck.json();

  expect(body).toHaveProperty('isAlive');

  expect(body.isAlive).toBe(true);

});

⬇️ Запуск smoke тестов:

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

В Smoke-тестах НЕ используется storageState и retries .

Пример конфига
import { defineConfig } from '@playwright/test';

export default defineConfig({

  projects: [

     {
      // smoke tests
      name: 'health_check',
      testMatch: /.*\.HealthCheck.spec\.ts/,
      testDir: './tests/smoke',
      fullyParallel: true,
      retries: 0,
    },
    {
      // setup auth выполняется первым и создаёт файлы в .auth/
      name: 'setup auth',
      testIgnore: /.*\.HealthCheck.spec\.ts/, //игнорирует запуск смоук тестов
      testMatch: /.*\.setup\.ts/, // запускает setup
      testDir: './tests',
      fullyParallel: true,
    },
    { 
      name: 'chrome'
      testIgnore: /.*\.HealthCheck.spec\.ts/, //игнорирует запуск смоук тестов
      testDir: './tests', 
      fullyParallel: true,
      dependencies: ['setup auth'], // запуск тестов только после setup auth
      retries: 1,
   }
  ]
});

❗Существует несколько способов запускать smoke-тесты, но самый простой - это регулировать запуск в gitlab.yaml - то есть, если стадия smoke не прошла, то дальнейшие тесты на ветке не запускаются, джоба помечается как красная. Например, вот. можно почитать о пайплайнах

Примеры запуска смоук тестов в CI|CD
# Если джоба с smoke тестами упадёт - пайплайн остановится 
# Джоба с остальными тестами не будет выполненапри условии если смоук не успешен

stages:
  - smoke
  - test

# 1) Прогоняем только смоук
smoke:
  stage: smoke
  image: mcr.microsoft.com/playwright:v1.54.0-jammy
  before_script:
    - npm ci
  script:
    - npx playwright test --grep "@smoke"
  allow_failure: false   
  when: on_success  

# 2) Остальные тесты — запускаются ТОЛЬКО если прошёл smoke
e2e_rest:
  stage: test
  image: mcr.microsoft.com/playwright:v1.54.0-jammy
  before_script:
    - npm ci
  script:
    - npx playwright test --grep-invert "@smoke"
  needs: ["smoke"]   
# либо стопаем запуск всех тестов 
# когда смоук тесты упали через условие
script:
    - |
      npx playwright test tests/smoke/HealthCheck.spec.ts
      if [ $? -ne 0 ]; then
        exit 1
      else
        npx playwright test --grep-invert "@smoke"
      fi

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

Цель E2E теста - проверить что все части системы, например фронтенд, бэкенд работают вместе и дают ожидаемый результат для пользователя.

Путь к E2E тестам: playwrightTests/tests/e2e

⚠️ В E2E-тестах, где требуется предварительная авторизация, можно указать, какой storageState использовать. Подробнее о том, что такое storageState и как применять его для авторизации перед запуском тестов, рассказано в первой части (Ауетентификация в автотестах - Глава 3. Ауетентификация в автотестах)

⬇️ Codegen - как помощник в написании теста:

Часто при написании теста сталкиваются с проблемой как найти локатор и тут Playwright может помочь фичей как кодегенерация (подробнее в офф доке -> Generating tests).

Часто при написании тестов возникает вопрос, как правильно найти локатор. В этом может помочь встроенная в Playwright фича - кодогенерация (подробнее в офф доке -> Generating tests).

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

  • не формализует константы и локаторы

  • не предусматривает их переиспользование в PageObject или ComponentObject.

И вот суть такова, сгенерированый код помогает быстро накидать черновик или ветхую основу теста, "НО" насколько он будет сьтабилен, можно ли быть увереным, что те локаторы которые используются в тесте будут всегда стабильными?

Я всё это к тому, что если пишете е2е тест, НО не знаете с чего начать - пользуйтесь codegen (штука хорошая), но потом обязательно:

  • добавляйте test-id атрибуты к UI-элементам, которые будут использоваться в автотестах

  • декомпозируйте код теста на логически разделённые части. Также можете выносить эти части в Page Object или оборачивать в test.step (test.step) -> чтобы визуально было легче читать из чего состоит тест в коде и в репорте, а не полотно.

  • выносите элементы в Page Object, если они относятся только к одной странице или в Component Object, если элементы общие для нескольких страниц.

⬇️ Логирование в e2e (test.info):

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

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

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

Преимущества такого подхода:

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

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

Для логирования понадобится опция  test.info!

 test.info удобно использовать:

  • для логирования REST-запроса (включая query-параметры и заголовки):

Пример
 const response = await request.get('url + query parameters');

    // логируем ответ в отчёт
    await test.info().attach('API request', {
        body: JSON.stringify(response, null, 2),
    });
  • а также логировать полученный ответ, например, для e2e теста -> то есть записать и сохранить какие товары получены в предусловии автотеста на проверку сортировки товаров :

Пример
   test.beforeEach(async ({ page, request }) => {
    createdProducts = await createManyProductsByCategory(
      request,
      CATEGORIE_NAMES.animalCare,
      LIMIT
    );

    // логируем ответ в отчёт
    await test.info().attach('Created products', {
        body: JSON.stringify(createdProducts, null, 2),
        contentType: 'application/json',
    });
  });
  • либо для сохранения скриншотов:

Пример

  // делаем скриншот
  const screenshot = await page.screenshot();

  // прикладываем скриншот в отчет
    await test.info().attach(
        'screenshots',
       { body: screenshot, contentType: 'image/png' }
  );

В отчете будет выглядеть так:

В playwright html отчете - test.info будет отображаться в Attachments:

test info в attachments в playwright html отчете
test info в attachments в playwright html отчете

На картинке, 'API request' и 'API response' лежат внутри Attachments. Если раскрыть каждый то можно увидеть тело запроса и тело ответа

Пример отображения test.info

Для примера использовался запрос с публичного Star Wars API.

Отображение тела запроса в раскрытом API request:

Отображение тела запроса
Отображение тела запроса

Отображение тела ответа в раскрытом API response:

Отображение тела ответа
Отображение тела ответа

⬇️ Разбираем e2e автотест:

Теперь, соберем автотест в котором будут и test.describe, beforeEach, test with tag, test.step, afterEach c учетом использования вспомогательной части к автотестам.

Пример:

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

У всех автотестов генерация или получение товаров будет в предусловии в beforeEach.

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

  • rating - значение от 1 до 10

  • recommendedLevel - значение от 1 до 10

Отсюда, очевидно, что для автотестов как сортировка по рейтингу, рекомендованным нужен отдельный beforeEach, в котором будет генерироваться или подготавливаться список товаров, который уже соответствует требованиям по дополнительным параметрам (например, наличие rating, recommendedLevel).

Разбор примера как можно разбить автотесты схожие по тематике но отличающиеся по реализации смотрите в примере:

Пример

В первой группе будут автотесты на сортировку по цене и максимальное колличество товаров на странице 5

//SortProductsList.spec.ts - файл с автотестами на сортировку

import { 
    CATEGORIE_NAMES,
    AUTH_ROLE_STATES
} from 'playwrightTests/common/constants/..'
import  { PRODUCT_PRICE } from '..test-ids.ts' 
import  { 
     createManyProductsByCategory, 
     deleteProductById 
} from 'playwrightTests/common/helpers/..' 
import .. from 'playwrightTests/common/pages/..'
import ..from  'playwrightTests/common/steps/..' 


let productPage

// выношу предусловие и тесты на проверку сортировки товаров 
// по цене в отдельную группу 
// и добавляю тег как sortProducts

test.describe({tag:'@sortProducts'}, async() => {
   let createdProducts: {} 
   const LIMIT = 5 

   // storageState для всех тестов в этой группе (кастомер 1)
   test.use({ storageState: AUTH_ROLE_STATES.customer.first });

   // В предусловии генерится 5 товаров в разделе электроника
   test.beforeEach(async ({ page, request }) => {
        productsPage = new ProductListPage(page);
        createdProducts = await createManyProductsByCategory(
          request,
          CATEGORIE_NAMES.electronics,
          LIMIT
       );

       await test.step('Переход на страницу электроники', async () => {
         await page.goto(`/products?category=${CATEGORIE_NAMES.electronics}`);
       });

       ......
  });


  // отдельная функция для получения цен по каждому товару на странице
  async function getProductPrices(page) {
     const priceTexts = await page.getByTestId(PRODUCT_PRICE).allTextContents();
    
     return getClearPrices(priceTexts);
  }


  test('Сортировка товаров по убывающей цене', async ({ page }) => {
      await test.step('Отсортировать по убыванию цены', async () => {
          await products.sortByPriceDesc();
      });

      await test.step('Товары отсортированы по убыванию цен', async () =>  {
         const prices = await getProductPrices(page)
  
         await products.toBeSortedInDescendingOrder(prices);
      });
  })


  test('Сортировка товаров по возрастающей цене', async ({ page }) => {
      const products = new ProductListPage(page);
         
      await test.step('Отсортировать по цене по возрастанию', async () => {
          await products.sortByPriceAsc();
      }); 
      
      await test.step('Товары отсортированы по возрастанию цен', async () => {
          const prices = await getProductPrices(page)
  
          await products.toBeSortedInAscendigOrder(prices);
      });
  });

  //Постусловие  в котором удаляются сгенеренные ранее товары
  test.afterEach(async ({ request }) => {
     for (const product of createdProducts) {
        await deleteProductById(request, product.id);
      }
   });
})


------------------------

//здесь должна быть вторая группа тестов

Добавляю вторую группу тестов для проверки сортировки товаров по рейтингу, рекомендованным (rating, recommendedLeve) в разделе товары для животных, где максимальное колличество товаров 20.

// выношу предусловие и тесты на проверку сортировки товаров 
// по рейтингу и рекомендованным 
// и добавляю тег как sortProducts

test.describe('вторая группа', {tag:'@sortProducts'}, async() => {
   let createdProducts: {} 
   const LIMIT = 20 

   // storageState для всех тестов в этой группе (кастомер 2)
   test.use({ storageState: AUTH_ROLE_STATES.customer.second });

   // В предусловии генерится 20 товаров в разделе товары для животных
   test.beforeEach(async ({ page, request }) => {
        productsPage = new ProductListPage(page);
        createdProducts = await createManyProductsByCategory(
          request,
          CATEGORIE_NAMES.animalCare,
          LIMIT,
          getRandom(MIN_RATING, MAX_RATING), // рейтинг
          getRandom(MIN_LEVEL, MAX_LEVEL), // уровень рекомендованности
       );

       await test.step('Переход на страницу товары для животных', async () => {
           await page.goto(`/products?category=${CATEGORIE_NAMES.animalCare}`);
       });

       ......
   });


   test('Сортировка товаров по рейтингу', async ({ page }) => {
       await test.step('Отсортировать по цене по рейтингу', async () => {
          await products.sortByRating();
       });
  
       await test.step('Товары отсортированы по рейтингу', async () =>  {
           .....      
          await products.toBeSortedInRatingOrder(createdProducts);
      });
  })


  test('Сортировка товаров по цене', async ({ page, request }) => {
      const products = new ProductListPage(page);
         
      await test.step('Отсортировать по рекомендованным', async () => {
         await products.sortByRecomended();
      });
  
      await test.step('Товары отсортированы по рекомендованным', async () =>  {
        ....      
        await products.toBeSortedInRecomendedOrder(createdProducts);
    });
   });

  //Постусловие  в котором удаляются сгенеренные ранее товары
  test.afterEach(async ({ request }) => {
     for (const product of createdProducts) {
        await deleteProductById(request, product.id);
      }
   });
})

Итог примера:

  • тесты сгруппированы. У каждой группы свой beforeEach/afterEach (предусловия/постусловия)

  • тесты разбиваются на логические шаги с помощью test.step

  • константы импортируются из файлов в playwrightTest/common/constants/{some-constant.ts}.

  • API-методы обёрнуты во вспомогательные функции (например, deleteProductById, createManyProductsByCategory) и хранятся в playwrightTest/common/helpers/{some-helper.ts}.

  • значения test-id (data-test-id/data-testId) вынесены в отдельный файл с идентификаторами, который хранится, например, в директории .../test-ids (вне структуры playwrightTests).

  • методы работы со страницей - такие как sortByPriceAsc, toBeSortedInDescendingOrder, sortByPriceDesc реализованы в PageObject ProductListPage

⬇️ Скриншотные проверки в e2e:

Представьте, что нужно написать тест с визуальной проверкой "сравнением действительного скриншота с ожидаемым". Такая проверка делает скриншот страницы, компонента или отдельного элемента со статическим контентом (не зависящий от данных) и сравнивает его с ожидаемым скриншотом, который хранится в папке playwrightTests/snapshots.

Легко же! — скажете вы. И действительно, это довольно изи.

? О том, где именно хранятся скриншоты, уже написано в первой части Вспомогательная часть к автотестам: Screenshots (snapshots)

⚠️ Обновлять скриншоты следует локально с помощью команды:

npx playwright test --update-snapshots

А если тесты с визуальными проверками размечены тегами, то можно запускать обновление выборочно, указав тег (@screenshots - это просто имя тега):

npx playwright test --update-snapshots --grep @screenshots 

⚠️ Для сравнения скриншотов используется функция: toHaveScreenshot(name)

В аргументах этой функции можно задавать разные опции опции

Самые часто применяемые это maxDiffPixels и maxDiffPixelRatio , что позволяют задать максимально допустимое отклонение между ожидаемым и фактическим скриншотом.

maxDiffPixelRatio = (число отличающихся пикселей) / (общее число пикселей в картинке)

Например, если указать maxDiffPixelRatio: 0.02, это будет означать, что допускается до 2% расхождений. Для снимка размером 1 000 000 пикселей 2% составляют 20 000 пикселей. Соответственно, если различий между фактическим и ожидаемым скриншотом ≤ 20 000 пикселей, визуальная проверка пройдёт успешно

⚠️ Допустимое различие в пикселях при сравнении скриншотов можно задавать двумя способами:

1) В конфиге playwright.config.ts - глобально для всех визуальных проверок:

 expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,       // абсолютное число пикселей
      maxDiffPixelRatio: 0.01, // или процентное соотношение
    },
 }

2) Локально, в базовой функции для сравнения ожидаемого и фактического снимка, например в playwrightTests/common/base/Base.ts

/**
 * Сравнивает фактический и ожидаемый скриншот
 *
 * @param target страница или локатор (что нужно заскринить)
 * @param name имя файла скриншота (если не задано, Playwright сам подставит)
 * @param increasedDiff  множитель допустимого расхождения (по умолчанию 1)
 * @param options дополнительные опции Playwright expect (threshold, animations)
 */
async areScreenshotsMatch(
  target: Locator | Page,
  name?: string,
  increasedDiff = 1,
  options: Record<string, unknown> = {}
) {
  const diffPixelsRatio = 0.1 * increasedDiff; //допустимое расхождение в пикселях
  const diffPixels = 100 * increasedDiff; 

// использую софт ассерт - чтобы тест продолжил выполняться
//даже если этот шаг зафейлится
  await expect.soft(target).toHaveScreenshot(name ?? '', {
    maxDiffPixelRatio: diffPixelsRatio,
    maxDiffPixels: diffPixels,
    ...options,
  });

}

❗Кстати есть ньюанс:

Если задать одновременно maxDiffPixels: 200 и maxDiffPixelRatio: 0.2, то условия работают по связке И.

Это означает, что проверка пройдёт только в том случае, если количество отличающихся пикселей не превышает 200 и одновременно не превышает 20% от общего числа пикселей. Если хотя бы одно из условий нарушается - > сравнение упадёт.

Например: картинка 200 × 200 = 40 000 пикселей, maxDiffPixelRatio: 0.2, maxDiffPixels: 100.

Так как условия работают по связке И, проверка пройдёт только если различие ≤ maxDiffPixels ( ≤ 100). Реальный порог = 100 пикселей

await expect.soft(target).toHaveScreenshot(name ?? '', {
  maxDiffPixelRatio: 0.2, // до 20% отличий
  maxDiffPixels: 100,     // и одновременно не больше 100 пикселей
  ...options,
});

Тесты с визуальной проверкой:

Немного о примерах исапользования визуального сравнения:

Пример_1:

Нужно проверить отображение иконок товаров в корзине, где все данные о товарах замоканы.

Путь к этому тесту ./tests/e2e/Basket.spec.ts

Пример теста с визуальной проверкой
import { test } from '@playwright/test';
import { areScreenshotsMatch } from '../common/base/Base';
import { BASKET_CONTENT } from '../common/test-ids';
import { BasketPreviewComponent } from '../common/components/BasketPreviewComponent';
import { getInfoProductsInBasket } from '../common/helpers/basket-helpers';

test('View products details and icons in basket', { tag: '@screenshots' }, async ({ page }) => {
  const basketPreview = new BasketPreviewComponent(page);

  await test.step('Мокаем ответ на запрос /getInfoProductsInBasket', async () => {
    await getInfoProductsInBasket(page); // мок вынесен в helper
  });

  await test.step('Открываем корзину', async () => {
    await basketPreview.openBasket();
  });

  await test.step('Сравниваем отображение товаров в корзине', async () => {
    await areScreenshotsMatch(page.getByTestId(BASKET_CONTENT), '');
  });
});

Имя ожидаемого скриншота будет: View-products-details-and-icons-in-basket-1.png

Сам ожидаемый скриншот будет лежать в /screenshots/desktop-chrome/e2e/Basket.spec.ts/View-products-details-and-icons-in-basket-1.png

Пример_2:

Проверяем отображение меню сайта со статическим контентом, где наименования категорий и подкатегорий товаров не изменяется.

Путь к этому тесту ./tests/e2e/Catalog.spec.ts

Пример теста 2 с визуальной проверкой
import { test } from '@playwright/test';
import { areScreenshotsMatch } from '../common/base/Base';
import { EXPANDED_CATALOG_CONTENT } from '../common/test-ids';
import { CatalogComponent } from '../common/components/CatalogComponent';

test('Show catalog and expanded subcatalog', { tag: '@screenshots' }, async ({ page }) => {
  const catalogContent = new CatalogComponent(page);

  await test.step('Открываем каталог', async () => {
    await catalogContent.openCatalog();
  });

  await test.step('Открываем подраздел - Товары для животных', async () => {
    await catalogContent.openSubCatalog(CATEGORIES.animalCare);
  });

  await test.step('Сравниваем отображение раскрытого каталога', async () => {
    await areScreenshotsMatch(page.getByTestId(EXPANDED_CATALOG_CONTENT), '');
  });
});

Имя ожидаемого скриншота будет: Show-catalog-and-expanded-subcatalog-1.png

Сам ожидаемый скриншот будет лежать в /screenshots/mobile-chrome/e2e/Catalog.spec.ts/Show-catalog-and-expanded-subcatalog-1.png

❗Для локального запуска удобно использовать команды:

  • Запуск конкретного файла с фильтром по тегу:

npx playwright test Catalog.spec.ts --grep @screenshots

Запуск в интерактивном UI-режиме (удобнее, так как в интерактивном интерфейсе сразу отображаются фактический и ожидаемый результаты во вкладке Attachments , если тест упал - Trace Viewer):

npx playwright test --ui

⚠️ Настройка запуска e2e автотестов в конфиге:

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

В Playwright это удобно настраивается через конфиг в playwright.config.ts.

Бывают случаи когда нужно запустить только определённый файл тестов в мобильном Chrome, или наоборот, один и тот же тестовый файл должен прогоняться в нескольких проектах (разных браузерах/устройствах).

Про игнорирование прогона определеных тестов - упомянула в прошлой статье (искать по заголовку "Config").

⚠️ Внимание: Если нужно, чтобы определённые файлы с тестами (.spec.ts) выполнялись только в конкретных окружениях, их можно вынести в отдельную папку test-assets.

Например:

  • Basket.spec.ts будет лежать по пути:
    playwrightTests/tests/e2e/Basket/test-assets/Basket.spec.ts

  • ProductList.spec.ts будет лежать по пути:
    playwrightTests/tests/e2e/Basket/test-assets/ProductList.spec.ts

Такая структура позволяет явно настроить проекты в playwright.config.ts, чтобы каждый файл запускался только в нужных браузерах или устройствах.

Для примера будут использоваться следущие файлы с тестами как Basket.spec.ts и ProductList.spec.ts

Пример запуска ProductList.spec.ts
  • testIgnore: /.*Smoke|api.spec.ts/. -> игнорируется запуск смоук и апи тестов

  • testMatch: /.*ProductList.spec.ts/ -> запускается этот файл с тестами в 3-х projects

  • количество ритраев различается и указывается прям в каждом project,

  • devices и viewport для каждого project - Desktop Chrome|Mobile Chrome(Pixel 7)| Mobile Safari (iPad Air)

projects: {[
  ...// настройка запуска ProductList.spe.ts
  {
    name: 'desktop_chrome',
    testIgnore: /.*Smoke|api\.spec\.ts/,
    testMatch: /.*ProductList\.spec\.ts/,
    use: {
      ...devices['Desktop Chrome'],
      viewport: { width: 1280, height: 720 },
    },
    testDir: './tests',
    fullyParallel: false,
    dependencies: ['setup auth'], // запуск тестов только после setup auth
    retries: 1,
  },

---------

  {
    name: 'mobile_chrome',
    testIgnore: /.*Smoke|api\.spec\.ts/,
    testMatch: /.*ProductList\.spec\.ts/,
    use: {
      ...devices['Pixel 7'], // или другой mobile-профиль
      isMobile: true,
      viewport: { width: 400, height: 700 }
    },
    testDir: './tests',
    fullyParallel: true,
    retries: 2,
  },

--------

  {
    name: 'mobile_safari',
    testIgnore: /.*Smoke|api\.spec\.ts/, 
    testMatch: /.*ProductList\.spec\.ts/,
    use: {
      ...devices['iPad Air'],
      isMobile: true,
      viewport: { width: 820, height: 1180  },
    },
    testDir: './tests',
    fullyParallel: true,
    retries: 1,
  },

  ...// настройка запуска Basket.spe.ts
]}
  • Basket.spec.ts гоняем на:

    • devices['Desktop Chrome'] c viewport: { width: 1280, height: 720 }

    • devices['Pixel 7'] , viewport: { width: 412, height: 915 }

Пример запуска Basket.spec.ts
  • testIgnore: /.*Smoke|api.spec.ts/. -> игнорируется запуск смоук и апи тестов

  • testMatch: /.*Basket.spec.ts/ -> запускается этот файл с тестами в 3-х projects

  • количество ритраев различается и указывается прям в каждом project,

  • devices и viewport для каждого project - Desktop Chrome|Mobile Chrome(Pixel 7)

projects: {[
  ...// настройка запуска Basket.spe.ts
  {
    name: 'desktop_chrome',
    testIgnore: /.*Smoke|api\.spec\.ts/,
    testMatch: /.*Basket\.spec\.ts/,
    use: {
      ...devices['Desktop Chrome'],
      viewport: { width: 1280, height: 720 },
    },
    testDir: './tests',
    fullyParallel: true,
    dependencies: ['setup auth'], // запуск тестов только после setup auth
    retries: 1,
  },

---------
  {
    name: 'mobile_chrome',
    testIgnore: /.*Smoke|api\.spec\.ts/,
    testMatch: /.*Basket\.spec\.ts/,
    use: {
      ...devices['Pixel 7'], // или другой mobile-профиль
      isMobile: true,
      viewport: { width: 400, height: 700 }
    },
    testDir: './tests',
    fullyParallel: false,
    retries: 1,
  },
  ...
]}

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

  • Desktop Chrome| Desktop Safari| Mobile Chrome| Mobile Safari

  • где viewport: { width: 412, height: 915 }

  • или viewport: { width: 1280, height: 720 }

Для этого просто прогоняем все тесты, но игнорируем те что лежат в test-assets папке.

Пример настройки прогона автотестов в мобильном и десктопном браузере

Настройка запуска автотестов на мобильных версиях популярных бараузеров:

{
  name: 'mobile_chrome',
  // Игнорируем все тесты из test-assets (они запускаются в отдельных проектах)
  testIgnore: ['**/test-assets/**'],
  testDir: './tests',
  fullyParallel: true,
  use: {
    ...devices['Mobile Chrome'],
    isMobile: true,
    viewport: { width: 360, height: 780 },
  },
},
{
  name: 'mobile_safari',
  testIgnore: ['**/test-assets/**'],
  testDir: './tests',
  fullyParallel: true,
  use: {
    ...devices['iPhone 13'],
    isMobile: true,
    viewport: { width: 393, height: 852 },
  },
},

Настройка запуска автотестов на десктопных версиях популярных бараузеров:

{
  name: 'desktop_chrome',
  // Игнорируем все тесты из test-assets (они запускаются в отдельных проектах)
  testIgnore: ['**/test-assets/**'],
  testDir: './tests',
  fullyParallel: true,
  use: {
    ...devices['Desktop Chrome'],
      viewport: { width: 1280, height: 720 },
  },
  retries: 1,

},
{
  name: 'desktop_safari',
  testIgnore: ['**/test-assets/**'],
  testDir: './tests',
  fullyParallel: true,
  use: {
    ...devices['Desktop Safari'],
      viewport: { width: 1280, height: 720 },
  },
   retries: 1,
},

⚠️ Ожидания (Таймауты) в e2e:

Все просто:

Желательно не использовать таймауты как waitForTimeout(). Даже в официальной документации отмечено что такой метод подходит только для дебагинга тестов, а не для рабочих тестов. waitForTimeout(5000) останавливает выполнение теста на 5 сек.

Вместо этого используйте:

  • появления/исчезновения элемента await expect(locator).toBeVisible({ timeout: milliseconds });

    || await expect(locator).toBeHidden({ timeout: milliseconds })

  • или наступления события (например, загрузки страницы) await page.waitForLoadState('networkidle');


3) API-тесты - это проверка работы эндпоинтов бэкенда. Они направлены на то, чтобы убедиться, что сервис:

  • Корректно отвечает на запросы (эндпоинт доступен).

  • Возвращает правильный HTTP-код ответа (200, 201, 400, 401, 404 и т.д.).

  • Содержит ожидаемую структуру (валидный JSON/XML, нужные поля).

  • Возвращает корректные данные для позитивных сценариев.

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

В прошлой статье я упоминала, что для написания апи теста пригодится следующее:

  • API-хелпер - базовый хелпер для работы с запросами, который будет основой всех API-вызовов и возвращать ответы;

  • Steps - шаги для выполнения API-действий, построенные на основе хелпера;

  • Schemas - отдельные структуры для хранения и проверки формата ответов.

  • apiRoutes - названия ендпоинтов

⚠️ Схемы ответов (Schemas) в автотестах:

Cхема - это описание структуры ожидаемого ответа на API запрос.

Схема используется для валидации контрактов, то есть для проверки, что API возвращает данные в ожидаемом формате.

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

Схемы рекомендуется хранить в отдельной папке schemas внутри директории common. То есть все схемы лежат в /common/schemas/{feature}-schemas.ts.

Лучше выносить схемы ответов в отдельную папку /common/schemas/, чтобы не смешивать их с константами.

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

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

Отдельная директория /common/schemas позволяет поддерживать более чистую и понятную структуру для автотестов.

Подробнее о схемах ответов на запросы и примерах их использования в тестах можно найти в прошлой статье.

Смотреть под тайтлом "Схемы API-ответов" здесь.

⚠️ apiRoutes (Ендпоинты):

Эндпоинты вынесены в отдельную папку /common/apiRoutes/{feature}-routes.ts

Пример содержимого в /common/apiRoutes/authorization-routes.ts:

// Все эндпоинты для авторизации
export const AUTH_ROUTES = {
  POST_LOGIN: '/api/v1/auth/login',
  .....
};

Плюс такого подхода в том, что все пути API-запросов хранятся централизованно, и при изменении эндпоинта достаточно внести правку только в одном месте.

В функции, где реализуется вызов API-запроса, используется соответствующий эндпоинт, вынесенный в отдельный файл из папки apiRoutes.

import { doPostRequest } from '/common/helpers/api-base-helper';
import { POST_LOGIN } from '/common/apiRoutes/authorization-routes.ts'


test('Пример', async ({ request }) => {
  const response = await doPostRequest(
     request,
     POST_LOGIN, 
    .....
   );

Подробнее об эндпоинтах и примерах их использования в тестах можно найти в прошлой статье.

Смотреть под заголовком "ApiRoutes" здесь.

⚠️ API-хелпер:

В прошлой статье, в разделе про вспомогательные части (см. «⚠️ Хелперы также можно использовать для API-запросов»), я приводила пример хелпера, где в одной функции объединены отправка запроса и возврат ответа в JSON-формате и в текством виде если код ответа на запрос не 200.

Исходя из этого, у нас есть базовый хелпер doRequest, который инкапсулирует общую логику отправки HTTP-запросов. На его основе реализованы функции (doGetRequest, doPostRequest, doPutRequest, doPatchRequest, doDeleteRequest), каждая из которых работает со своим HTTP-методом

Пример вызова одной из таких функций для HTTP-метода  в тесте:

Пример использования doGETRequest(...)
import { POST_LOGIN } from 'playwrightTests/common/apiRoutes/..'
import { 
   SUCCESS_LOGIN_SCHEMA 
} from 'playwrightTests/common/schemas/auth-schemas.ts'


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

  const response = await doPostRequest(
     request,
     POST_LOGIN,
     {
       'email': 'emailTest@testUser.com',
       'password': 'Password123!',
     }
   );
  
    expect(response.status).toBe(200)
    expect(response.body.settings).toBeTruthy()
    expect(response.body).toMatchObject(SUCCESS_LOGIN_SCHEMA);
})

В примере показан успешный кейс, когда код ответа равен 200, а JSON-ответ соответствует схеме SUCCESS_LOGIN_SCHEMA.

Однако в нём не рассматриваются негативные сценарии и не охвачены все позитивные варианты. Кроме того, стоит обратить внимание, что при вызове doGetRequest с разными параметрами будет повторяться один и тот же вызов doGetRequest в разных тестах и дублировать проверку ответа на запрос как:

  expect(response.status).toBe(200)

  expect(jsonBody.settings).toBeTruthy()

  expect(jsonBody).toMatchObject(SUCCESS_LOGIN_SCHEMA);

⚠️ Step pattern для API-тестов:

Чтобы избежать дублирования одного и того же кода в разных тестах, стоит использовать step-паттерн.

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

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

Такой подход упрощает сопровождение и делает тесты более читаемыми

Для примера, будут кейсы на ауетенфикацию через API.

Позитивные кейсы:

1-кейс: успешная авторизация с валидными e-mail и паролем

Негативные кейсы:

2-кейс: попытка авторизации с незарегистрированным пользователем

3-кейс: авторизация с неверными данными (e-mail или пароль)

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

Пример 1:

Для кейсов 1 (позитивного), а также 2 и 3 (негативных) используется эндпоинт /login, в который параметры userLogin и password передаются через query.

Запрос doPostRequest оберну в вспомогательную функцию sendLogin, которая принимает только значения e-mail и пароля, без строгой типизации, и отвечает за отправку запроса на аутентификацию.

Пример степа sendLogin(..) в /common/steps/api/auth-step.ts
//common/steps/api/auth-step.ts

import { POST_LOGIN } from 'playwrightTests/common/apiRoutes/...'

/**
* Отправка запроса на авторизацию
*/
export async function sendLogin(
  request: APIRequestContext,
  email: string,
  password: string
) {
   return await doPostRequest(
     request,
     POST_LOGIN,
     {
       'email': email,
       'password': password,
     }
   );
}


/**
* Проверяет код ответа
*/
export function verifyResponseCode(
  response: any,
  statusCode: number
) {
   expect(response.status).toBe(statusCode);
}


/**
* Проверяет структуру ответа
*/
export function verifyResponseStructure(
  response: any,
  structure: object
) {
   expect(response.body).toMatchObject(structure);
}

Далее преобразуем тест таким образом, чтобы в нём вызывались функция и методы sendLogin, verifyResponseCode, verifyResponseStructure , а затем выполнялась проверка ответа при успешной аутентификации.

Пример использования степ функции в тесте (позитивная проверка)

1-кейс: успешная авторизация с валидными e-mail и паролем:

import { POST_LOGIN } from 'playwrightTests/common/apiRoutes/..'
import { 
   SUCCESS_LOGIN_SCHEMA 
} from 'playwrightTests/common/schemas/auth-schemas.ts'
import { 
   sendLogin, 
   verifyResponseCode,
   verifyResponseStructure
} from 'playwrightTests/common/steps/api/auth-step.ts'


test('Успешная авторизация с валидными e-mail и паролем', async ({ request }) => {
  let response;

  await test.step('Отправка запроса', async () => {
      response = await sendLogin(
          request,
          'emailTest@testUser.com',
          'Password123!'
      );
  });

  // логируем ответ в отчёт
  await test.info().attach('API response', {
      body: JSON.stringify(response, null, 2),
      contentType: 'application/json',
  });

  await test.step('Проверка кода и структуры ответа', async () => {
       verifyResponseCode(response, 200);
       verifyResponseStructure(response, SUCCESS_LOGIN_SCHEMA)
  });

  await test.step('Проверка, что массив settings в ответе не null', async () => {
      expect(response.body.settings).toBeTruthy();
  });
});

Теперь нужно добавить тесты по негативным проверкам ауетенфикации c использованием функции и методов sendLogin, verifyResponseCode, verifyResponseStructure:

Пример использования степ функции в тесте (негативная проверка)
test('Попытка ауетенфикации с незарегистрированным пользователем', async ({ request }) => {
  let response;
  const notExistingEmail = 'johnDoe12345@testUser.com';

  await test.step('Отправка запроса', async () => {
      response = await sendLogin(
          request,
          notExistingEmail,
          'Password123!'
      );
  });

   // логируем ответ в отчёт
  await test.info().attach('API response', {
      body: JSON.stringify(response, null, 2),
      contentType: 'application/json',
  });

  await test.step('Проверка кода и структуры ответа', async () => {
       verifyResponseCode(response, 404);
       verifyResponseStructure(
         response, 
         returnUnknownUserSchema(ERROR_CODE, notExistingEmail)
       );
  });
});

Кейс -3: ауетенфикация с неверными данными (e-mail или пароль):

test('Ауетенфикация с неверными данными', async ({ request }) => {
  let response;

  await test.step('Отправка запроса', async () => {
      response = await sendLogin(
          request,
          'emailTest@testUser.com',
          'wrongPassword'
      );
  });

   // логируем ответ в отчёт
  await test.info().attach('API response', {
      body: JSON.stringify(response, null, 2),
      contentType: 'application/json',
  }); 

  await test.step('Проверка кода и структуры ответа', async () => {
       verifyResponseCode(response, 401);
       verifyResponseStructure(
         response, 
         INVALID_AUTH_SCHEMA
       );
  });
});

⚠️ Итог: что следует соблюдать при написании API-теста:

  • Есть базовый переиспользуемый хелпер для всех HTTP-запросов.

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

  • Запрос и проверка разделены на разные степы.

  • Шаги для проверки ответов (код ответа, структура и т.п.) вынесены в отдельные степы.

  • Все эндпоинты вынесены в apiRoutes и импортируются в степах или тестах.

  • Все схемы (структуры) ответов вынесены в отдельную папку schemas и вызываются в тестах.

  • В схемах нет хардкода, значения при необходимости передаются через аргументы функций.

  • В тестах достаточно ассертов как для позитивных, так и для негативных сценариев.

  • В тестах, при необходимости используется test.info для логирования или дополнительной информации


Заключение

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

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

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

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


  1. Paczuk
    15.09.2025 04:35

    Спасибо за пост! Можете ли порекомендовать какие-то репозиторий, где выложены проекты с тестами целиком для большей наглядности?