Search and filters testing checklist — and how to automate every item with Playwright
Search and filters live in almost every product — and the bugs repeat from project to project. This post is in two parts: first a checklist of what to test, then how to automate every item with Playwright. All examples are in TypeScript, but the idea carries over to any UI framework.
The key tool: network interception
Half of the interesting cases can’t be reproduced reliably without controlling the network: a slow response, a backend error, an empty result set, a request race. So the foundation of search automation is page.route(): intercept the API request and decide yourself what to return and when. That makes tests deterministic. See Network and Mock APIs in the Playwright docs.
1. The query: edge cases
Checklist: empty query and whitespace only; 1 character; very long; special characters and operators (" * - :); unicode/emoji; leading/trailing whitespace (trim); case.
Automation — parametrize the “evil” queries and assert the page neither crashes nor returns a 500:
const queries = ['', ' ', 'a', 'x'.repeat(500),
'"exact phrase"', 'café', '🚀'];
for (const q of queries) {
test(`query doesn't break the page: ${JSON.stringify(q)}`,
async ({ page }) => {
await page.goto(`/search?q=${encodeURIComponent(q)}`);
await expect(page.getByRole('searchbox')).toBeVisible();
// no crash and no 500 error screen
});
}
2. Debounce: one request instead of a flood
Checklist: typing doesn’t fire a request on every keystroke — there’s debounce/throttle (usually 200–400 ms).
Automation — count the real API calls via interception:
test('search is debounced', 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); // longer than the debounce window
expect(calls.length).toBe(1); // not 6 requests for 6 letters
});
3. Request race: the last input wins
Checklist: if a slow response to an old query arrives after a fast response to a new one, the screen must keep the result of the latest input, not the one overwritten by the stale response. A classic live-search bug.
Automation — artificially slow down the old request:
test('request race: the latest wins', 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'); // slow one went out
await box.fill('abc'); // fast one went out
await expect(page.getByTestId('results'))
.toContainText('abc'); // shows the abc result
});
4. An empty state, not an empty list
Checklist: with zero results — a clear empty state with a hint (“nothing found”, a suggestion to reset filters), not a blank void and not a spinner forever.
test('no results: empty state is shown', 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(/nothing found/i))
.toBeVisible();
});
5. Injection and XSS in the query
Checklist: special characters and tags in the query are escaped when displayed (the query echo, “you searched for…”); ' OR 1=1 and <script> neither break the results nor execute. See OWASP XSS.
test('special characters are escaped', async ({ page }) => {
const xss = '<img src=x onerror=alert(1)>';
let fired = false;
page.on('dialog', () => { fired = true; });
await page.goto(`/search?q=${encodeURIComponent(xss)}`);
// the query echo is text, not HTML
await expect(page.getByTestId('query-echo')).toHaveText(xss);
expect(fired).toBe(false); // alert did not fire
});
6. Filters and facets + state in the URL
Checklist: filter combinations (AND/OR) are correct; “reset all” works; a narrow filter → a clear “nothing found”; filter state lives in the URL and survives reload and back/forward (sharing the link yields the same results).
test('filters in the URL survive reload and back',
async ({ page }) => {
await page.goto('/search?q=phone');
await page.getByRole('checkbox', { name: 'In stock' })
.check();
await expect(page).toHaveURL(/in_stock=1/);
await page.reload();
await expect(
page.getByRole('checkbox', { name: 'In stock' })
).toBeChecked(); // state restored
await page.goBack();
await expect(page).not.toHaveURL(/in_stock=1/);
});
7. Resilience: timeout and backend error
Checklist: 500/timeout/network drop yield a clear error state with “retry”, not a blank screen and not an eternal spinner; retry works.
test('backend error: a clear state', async ({ page }) => {
await page.route('**/api/search**',
r => r.fulfill({ status: 500 }));
await page.goto('/search?q=phone');
await expect(page.getByRole('alert'))
.toContainText(/retry|something went wrong/i);
});
This is also a convenient place to exercise a slow network: delay the response with setTimeout inside the route handler and check that a loader is shown rather than a freeze. More on stubbing responses — class Route, on matchers — assertions.
What NOT to automate in e2e
- Ranking quality. “Exact match above partial,” relevance — subjective and brittle in UI tests. Better a golden set at the search-backend/unit level, not Playwright.
- Morphology and synonyms. Stemming, word endings, typos — separate tests of the search engine, not e2e.
- Exploratory. Real “findability” and unexpected query phrasings — by hand. An automated test checks mechanics, not meaning.
The full checklist
Query:
- Empty / whitespace only / 1 char / very long — doesn’t break
- Trim leading and trailing whitespace
- Special characters and operators (
" * - :), unicode, emoji - Case-insensitivity (or explicitly documented otherwise)
Input behavior:
- Debounce/throttle — not a request per keystroke
- Request race — the latest input’s result is shown
- Autocomplete / recent queries / clearing the field
Results:
- No results → a clear empty state with a hint
- Pagination / infinite scroll without dupes or gaps
- Match highlighting, results count
Filters:
- Filter combinations (AND/OR) are correct
- Reset all filters
- State in the URL: reload, back/forward, link sharing
- Narrow filter → a clear “nothing found”
Resilience and security:
- 500 / timeout / drop → error state + retry
- Slow network → loader, not a freeze
- Injection and XSS in the query are escaped
- A large catalog doesn’t slow the UI
Localization:
- Search in another language/keyboard layout
- Diacritics (café = cafe), transliteration where needed
Bottom line
Search breaks predictably: request races, missing debounce, a blank screen instead of an empty state, filter state lost on reload. The good news — almost all of it is deterministically automatable via network interception (page.route). Take the checklist, turn each item into a test, and search regression stops being a lottery. Leave ranking quality and “findability” to a human: an automated test checks mechanics, not meaning.
Sources: Playwright — Network, Playwright — Mock APIs, Playwright — class Route, Playwright — Assertions, OWASP — Cross Site Scripting.