Поиск и фильтры есть почти в каждом продукте, и баги в них одни и те же из проекта в проект: запрос на каждую букву, гонка запросов, белый экран вместо «ничего не найдено», фильтры, которые слетают на 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. Если зашло — забегайте.
























