Поиск и фильтры есть почти в каждом продукте, и баги в них одни и те же из проекта в проект: запрос на каждую букву, гонка запросов, белый экран вместо «ничего не найдено», фильтры, которые слетают на 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 — Network, Playwright — Mock APIs, Playwright — class Route, Playwright — Assertions, OWASP — Cross Site Scripting.
Пишу про практику QA каждый день в Telegram-канале «QA — Quality Assurance»: t.me/qa10100011000001. Если зашло — забегайте.
ссылка на оригинал статьи https://habr.com/ru/articles/1050904/