Тестирование поиска и фильтров: чек-лист + как автоматизировать каждый пункт на Playwright

от автора

Поиск и фильтры есть почти в каждом продукте, и баги в них одни и те же из проекта в проект: запрос на каждую букву, гонка запросов, белый экран вместо «ничего не найдено», фильтры, которые слетают на reload. Хорошая новость — почти всё это детерминированно автоматизируется. Разбираем чек-лист того, что проверять, и для каждого пункта показываем рабочий автотест на Playwright через перехват сети.

Поиск кажется простым: поле ввода, запрос, список результатов. Но именно поэтому его тестируют поверхностно — «ввёл слово, что-то нашлось, ок». А реальные баги живут в граничных запросах, в тайминге (debounce, гонки), в пустых и ошибочных состояниях и в фильтрах. Ниже — двухслойный разбор: сначала что проверять, потом как это автоматизировать, с реальными сниппетами на TypeScript. Идея переносится на любой UI-фреймворк, но Playwright особенно удобен из-за встроенного перехвата сети.

Ключевая идея: контролируем сеть

Половину интересных кейсов невозможно стабильно воспроизвести, если не управлять ответами бэкенда: медленный ответ, ошибка 500, пустая выдача, гонка запросов. Если просто бить по реальному API, тесты будут флакать, а нужные состояния — не воспроизводиться по требованию.

Поэтому основа автотестов поиска — page.route(): перехватываем запрос к API и сами решаем, что и когда вернуть. Это делает тесты детерминированными и быстрыми. См. Network и Mock APIs в документации Playwright.

1. Запрос: граничные случаи

Что проверять: пустой запрос и только пробелы; один символ; очень длинный; спецсимволы и операторы (" * - :); unicode и emoji; ведущие/хвостовые пробелы (trim); регистр.

Автотест. Параметризуем «злые» запросы и проверяем, что страница не падает и не отдаёт 500:

const queries = ['', '   ', 'a', 'й'.repeat(500),  '"точная фраза"', 'café', '🚀'];for (const q of queries) {  test(`запрос не роняет страницу: ${JSON.stringify(q)}`,    async ({ page }) => {      await page.goto(`/search?q=${encodeURIComponent(q)}`);      await expect(page.getByRole('searchbox')).toBeVisible();      // нет краша и экрана ошибки 500    });}

Параметризация здесь — это не «красиво», а способ дёшево покрыть целый класс входов: добавить новый «злой» запрос — одна строка в массиве.

2. Debounce: один запрос вместо шквала

Что проверять: ввод не шлёт запрос на каждую букву — есть debounce/throttle (обычно 200–400 мс). Иначе на «iphone» прилетает шесть запросов, бэкенд получает лишнюю нагрузку, а UI — лишние перерисовки.

Автотест. Считаем реальные обращения к API через перехват:

test('поиск дебаунсится', async ({ page }) => {  const calls: string[] = [];  await page.route('**/api/search**', route => {    calls.push(route.request().url());    route.fulfill({ json: { results: [] } });  });  await page.goto('/search');  await page.getByRole('searchbox')    .pressSequentially('iphone', { delay: 50 });  await page.waitForTimeout(600); // дольше окна дебаунса  expect(calls.length).toBe(1);   // не 6 запросов на 6 букв});

3. Гонка запросов: показан ответ на последний ввод

Что проверять: если медленный ответ на старый запрос приходит позже быстрого на новый, на экране должен остаться результат последнего ввода, а не перетёртый устаревшим ответом. Это классический баг живых поисков: пользователь печатает «abc», но видит результаты по «ab», потому что тот ответ пришёл последним.

Автотест. Искусственно тормозим старый запрос и проверяем, что побеждает свежий:

test('гонка запросов: побеждает последний', async ({ page }) => {  await page.route('**/api/search**', async route => {    const q = new URL(route.request().url())      .searchParams.get('q');    if (q === 'ab') await new Promise(r => setTimeout(r, 1000));    await route.fulfill({ json: { results: [{ title: q }] } });  });  await page.goto('/search');  const box = page.getByRole('searchbox');  await box.fill('ab');   // улетел медленный  await box.fill('abc');  // улетел быстрый  await expect(page.getByTestId('results'))    .toContainText('abc'); // показан результат abc});

Без контроля над сетью этот баг почти невозможно воспроизвести стабильно — а здесь он становится обычным детерминированным тестом.

4. Пустое состояние, а не пустой список

Что проверять: при нуле результатов — внятный empty state с подсказкой («ничего не найдено», предложение сбросить фильтры или поправить запрос), а не белая пустота и не «крутилка» навсегда.

test('нет результатов: показан empty state', async ({ page }) => {  await page.route('**/api/search**',    r => r.fulfill({ json: { results: [] } }));  await page.goto('/search?q=zzxqwer');  await expect(page.getByTestId('empty-state')).toBeVisible();  await expect(page.getByText(/ничего не найдено/i))    .toBeVisible();});

5. Инъекции и XSS в запросе

Что проверять: спецсимволы и теги в запросе экранируются при отображении (эхо запроса, «вы искали…»); ' OR 1=1 и <script> не ломают выдачу и не исполняются. Поисковая строка часто отражается обратно в DOM — это классический вектор reflected XSS. См. OWASP XSS.

test('спецсимволы экранируются', async ({ page }) => {  const xss = '<img src=x>';  let fired = false;  page.on('dialog', () => { fired = true; });  await page.goto(`/search?q=${encodeURIComponent(xss)}`);  // эхо запроса — текстом, не HTML  await expect(page.getByTestId('query-echo')).toHaveText(xss);  expect(fired).toBe(false); // alert не сработал});

Перехват dialog — простой и надёжный сигнал: если alert() из инъекции сработал, тест это поймает.

6. Фильтры и фасеты + состояние в URL

Что проверять: комбинация фильтров (AND/OR) корректна; «сбросить всё» работает; узкий фильтр даёт внятное «ничего не найдено»; состояние фильтров живёт в URL и переживает reload и кнопки назад/вперёд. Последнее особенно важно: если состояние не в URL, пользователь не сможет ни поделиться ссылкой на выдачу, ни вернуться к ней кнопкой «назад».

test('фильтры в URL переживают reload и назад',  async ({ page }) => {    await page.goto('/search?q=phone');    await page.getByRole('checkbox', { name: 'В наличии' })      .check();    await expect(page).toHaveURL(/in_stock=1/);    await page.reload();    await expect(      page.getByRole('checkbox', { name: 'В наличии' })    ).toBeChecked();           // состояние восстановлено    await page.goBack();    await expect(page).not.toHaveURL(/in_stock=1/);  });

7. Устойчивость: таймаут и ошибка бэкенда

Что проверять: 500, таймаут и обрыв сети дают внятное состояние ошибки с «повторить», а не белый экран и не вечную крутилку; повтор работает.

test('ошибка бэкенда: внятное состояние', async ({ page }) => {  await page.route('**/api/search**',    r => r.fulfill({ status: 500 }));  await page.goto('/search?q=phone');  await expect(page.getByRole('alert'))    .toContainText(/повторить|что-то пошло не так/i);});

Здесь же удобно гонять медленную сеть: задержать ответ через setTimeout в обработчике route и проверить, что показан лоадер, а не зависание интерфейса. Подробнее про подмену ответов — class Route, про матчеры — assertions.

Что НЕ стоит автоматизировать в e2e

Автотест проверяет механику, а не смысл. Поэтому есть вещи, которые в e2e тащить не нужно:

  • Качество ранжирования. «Точное совпадение выше частичного», релевантность — субъективно и хрупко в UI-тестах. Лучше golden-set на уровне поискового бэка или юнитов, а не Playwright.

  • Морфология и синонимы. Стемминг, окончания, опечатки — отдельные тесты поискового движка, не сквозные.

  • Исследовательское тестирование. Реальная «находимость» и неожиданные формулировки запросов — руками. Тут человек незаменим.

Полный чек-лист

Запрос:

  • Пустой / только пробелы / 1 символ / очень длинный — не падает

  • Trim ведущих и хвостовых пробелов

  • Спецсимволы и операторы (" * - :), unicode, emoji

  • Регистронезависимость (или явно задокументировано иначе)

Поведение ввода:

  • Debounce/throttle — не запрос на каждую букву

  • Гонка запросов — показан результат последнего ввода

  • Автодополнение / недавние запросы / очистка поля

Результаты:

  • No results → внятный empty state с подсказкой

  • Пагинация / бесконечная прокрутка без дублей и пропусков

  • Подсветка совпадений, счётчик найденного

Фильтры:

  • Комбинация фильтров (AND/OR) корректна

  • Сброс всех фильтров

  • Состояние в URL: reload, назад/вперёд, шаринг ссылки

  • Узкий фильтр → внятное «ничего не найдено»

Устойчивость и безопасность:

  • 500 / таймаут / обрыв → состояние ошибки + повтор

  • Медленная сеть → лоадер, не зависание

  • Инъекции и XSS в запросе экранируются

  • Большой каталог не тормозит UI

Локализация:

  • Поиск на другом языке/раскладке

  • Диакритика (café = cafe), транслит при необходимости

Вывод

Поиск ломается предсказуемо: гонки запросов, отсутствие debounce, белый экран вместо empty state, состояние фильтров, теряющееся на reload. И почти всё это детерминированно автоматизируется через перехват сети (page.route): берёте чек-лист, превращаете каждый пункт в тест — и регресс поиска перестаёт быть лотереей. А качество ранжирования и «находимость» оставьте человеку: автотест проверяет механику, не смысл.

Источники: Playwright — NetworkPlaywright — Mock APIsPlaywright — class RoutePlaywright — AssertionsOWASP — Cross Site Scripting.


Пишу про практику QA каждый день в Telegram-канале «QA — Quality Assurance»: t.me/qa10100011000001. Если зашло — забегайте.

ссылка на оригинал статьи https://habr.com/ru/articles/1050904/