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

В этой статье я покажу, как построить гибкий и масштабируемый подход к тестированию фильтрации с помощью Playwright + TypeScript, используя: Page Object Model, Data-driven testing, конфигурацию фильтров и кастомные фикстуры.

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

? Задача

Представим страницу каталога с фильтрами:

  • «Только товары со скидкой»

  • «Цена от/до»

  • «Тип товара» (одежда, электроника и т. д.)

  • Кнопка Reset Filters

Нужно протестировать:

  1. Работу каждого фильтра по отдельности.

  2. Комбинации фильтров.

  3. Сброс фильтров.

  4. Корректные значения по умолчанию.

  5. Отсутствие результатов при невозможной комбинации.

Пример фильтруемого каталога с товарами
Пример фильтруемого каталога с товарами

? Page Object Model (POM) - это база

Подробней про POM можно почитать в документации Playwright'а.

Для начала давайте найдем и опишем локаторы, с которыми нам предстоит работать, для этого, создаем класс ItemsPageLocators :

import { Locators } from '../../base/locators'

export class ItemsPageLocators extends Locators {
  // Example locator
  itemCard(){
    return this.page.getByTestId('item-card').describe('Item card')
  }

  // List other locators: onlyDiscountCheckboxFilter, minPriceInputFilter, etc..

}

Далее, класс страницы ItemsPage - который будет отвечать за бизнес логику. В нём мы используем локаторы из созданного ранее класса ItemsPageLocators и сделаем методы по парсингу карточек товара, применению фильтров и валидации.

Примеры реализации методов ItemsPage

Парсинг карточек товара:

getItems(): Promise<Item[]>{
    return test.step('Get items', async ()=> {
      const arr: Item[] = []
      for (const [i, item] of (await this.locators.itemCard().all()).entries()) {
        await test.step(`Item #${i+1}`, async ()=> {
          const obj = {
            name: (await item.locator(this.locators.itemName()).textContent())!,
            type: (await item.locator(this.locators.itemType()).textContent())!,
            price: Formats.PRICE.parser((await item.locator(this.locators.itemPrice()).textContent())!),
            originalPrice: await item.locator(this.locators.itemOriginalPrice()).isVisible() 
              ? Formats.PRICE.parser((await item.locator(this.locators.itemOriginalPrice()).textContent())!)
              : undefined
          }
          expect(obj, `Item #${i+1} ${obj.name} to be parsed successfully`).toEqual(
            {
              name: expect.any(String),
              type: expect.any(String),
              price: expect.any(Number),
              originalPrice: obj.originalPrice ? expect.any(Number) : undefined
            }
          )
          arr.push(obj)
        })
      }
      return arr
    })
  }

Применение фильтра или множества фильтров:

  async filter(options: FilterOptions | FilterOptions[]){
    for (const { type, value} of Array.isArray(options) ? options : [options]) {
      await test.step(`Filter by ${type}: ${value}`, async ()=> {
        await getFilterConfig(type).apply(this.locators, value)
      })
    }
  }

Проверяем, что товары соответствуют выбранным фильтрам:

  async validate(options: FilterOptions | FilterOptions[]) {
    const items = await this.getItems()
    expect(
      items.length,
      `To have at least 1 filtered item`
    ).toBeGreaterThanOrEqual(1)
    for (const { type, value } of Array.isArray(options) ? options : [options]) {
      await test.step(`Validate filter by ${type}: ${value}`, async () => {
        const validate = getFilterConfig(type).validate
        for (const item of items) {
          await validate(value, item)
        }
      })
    }
  }

А так же проверка дефолтного состояния фильтров:

  async validateFilterDefaultState(){
    await test.step(`Validate all filter inputs default state`, async ()=> {
      const items = await this.getItems()
      for (const type of Object.values(ItemsFilters)) {
        await test.step(`Validate default state for ${type}`, async () => {
          await getFilterConfig(type).defaultValidate(this.locators, items)
        })
      }
    })
  }

⚙️ Конфигурация фильтров: выносим правила в отдельный слой

Вместо того, чтобы писать switch/case, if/else, внутри методов ItemsPage, мы выносим правила работы каждого фильтра в конфигурационный объект в filter-configs.ts:

import { expect } from '@playwright/test'
import { FilterConfig, ItemsFilters } from './types'

const filterConfigs: Record<ItemsFilters, FilterConfig> = {

  [ItemsFilters.MIN_PRICE]: {
    apply: async (locators, value) => {
      await locators.minPriceInputFilter().fill(String(value))
    },
    validate: (value, item) => {
      expect.soft(item.price, `${item.name} price to be >= ${value}`).toBeGreaterThanOrEqual(parseInt(String(value)))
    },
    defaultValidate: async (locators) => {
      await expect(locators.minPriceInputFilter(), `Min price to be 0`).toHaveValue('0')
    },
  },

  // ... List other filters bellow
}

Теперь добавление нового фильтра = новый элемент в enum ItemsFilters + запись в filter-configs.ts. Класс ItemsPage при этом менять не нужно

? Используем кастомные фикстуры

Что такое фикстуры и зачем они нужны можно почитать в документации playwright'а.
В нашем случае, в pages.fixtures.ts мы добавляем новую фикстуру itemsPage, которая будет открывать страницу ItemsPage и ждать, что страница загрузилась и готова к проведению тестирования:

  itemsPage: async ({ page }, use) => {
    const itemsPage = new ItemsPage(page)
    await itemsPage.open()
    await use(itemsPage)
  },

? И наконец-то - тестируем!

Теперь, когда всё готово к тестированию, переходим к созданию тест спеки(набора тестов) filterable-items.spec.ts. В тестах мы будем использовать data-driven подход.

Примеры реализации тестов из спеки filterable-items.spec.ts

В качестве параметров определим следующие фильтры:

  const filters: FilterOptions[] = [
    {
      type: ItemsFilters.MIN_PRICE,
      value: 50
    },
    {
      type: ItemsFilters.MAX_PRICE,
      value: 500
    },
    {
      type: ItemsFilters.ONLY_DISCOUNT,
      value: true
    },
    {
      type: ItemsFilters.TYPE,
      value: ['Clothing', 'Electronics']
    },
  ]

Эти параметры, мы используем для проверки каждого фильтра по отдельности:

  for (const filter of filters) {
    test(`Single filter › ${filter.type}: ${filter.value}`, async ({ itemsPage }) => {
      await itemsPage.filter(filter)
      await itemsPage.validate(filter)
    })
  }

А так же и комбинации фильтров:

  test(`Multiple filters`, async ({ itemsPage }) => {
    await itemsPage.filter(filters)
    await itemsPage.validate(filters)
  })

Сброс фильтров - проверяем при помощи:
- Проверки дефолтного состояния фильтров
- Сравнения списка товаров с первоначальным состоянием(до фильтрации):

  test(`Reset filters`, async ({ itemsPage }) => {
    const before = await itemsPage.getItems()
    await itemsPage.filter(filters)
    await itemsPage.locators.resetFiltersButton().click()
    await itemsPage.validateFilterDefaultState()
    const after = await itemsPage.getItems()
    expect(after,
      'Items array before filtering and after reset filters to be equal'
    ).toEqual(before)
  })

Проверка дефолтного состояния фильтров:

  test(`Filters default state`, async ({ itemsPage }) => {
    await itemsPage.validateFilterDefaultState()
  })

Негативный сценарий - задаем фильтры не совпадающие ни с одним товаром:

  test(`Nothing found`, async ({ itemsPage }) => {
    const filters: FilterOptions[] = [
      {
        type: ItemsFilters.MIN_PRICE,
        value: 300
      },
      {
        type: ItemsFilters.TYPE,
        value: ['Home']
      }
    ]
    await itemsPage.filter(filters)
    await expect(itemsPage.locators.itemCard()).toHaveCount(0)
  })
Результат тестов в Playwright UI mode
Результат тестов в Playwright UI mode

? Частые проблемы и их решения

  1. Асинхронное обновление страницы: После применения фильтров страница может обновляться с задержкой из-за запросов к API. Чтобы избежать ошибок, используйте await page.waitForResponse() или дожидайтесь исчезновения элементов лоадеров/скелетонов для того, чтобы понять когда произошло завершение загрузки.

  2. Хрупкость локаторов: Если структура страницы меняется, тесты могут ломаться. Рекомендуется использовать data-testid или другие стабильные селекторы.

  3. Тестирование пагинации: Если каталог поддерживает пагинацию, добавьте метод getAllItemsWithPagination в ItemsPage, который будет собирать товары со всех страниц.

  4. Данных в UI не хватает для валдиации всех фильтров: если карточка товара, не содержит нужного количества данных, для валидации фильтра, воспользуйтесь API - сделайте запрос информации о товаре и сравните его с заданным фильтрами.

? Итоги

Мы рассмотрели, как реализовать тесты фильтрации, которые:

  • Основаны на Page Object Model

  • Имеют конфигурацию фильтров

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

  • Тестируют при помощи data-driven подхода

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

  • Остаются читаемыми и поддерживаемыми

Такой подход помогает QA-команде тратить меньше времени на рутину и быстрее адаптироваться к изменениям продукта.

? Полный пример
Исходный код примеров доступен в репозитории:
? old-door/qa-playground

А ещё я подготовил демо-страницу со списком товаров и фильтрацией — можно запустить и попробовать тесты самому.

Ваше мнение?
Сталкивались ли вы с тестированием фильтрации?
Используете ли конфигурационный подход или обходились классическим POM?

Давайте поделимся best practices в комментариях ?

? Удачного тестирования, и пусть ваши фильтры всегда работают идеально!

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