checklistautomationplaywrightsearchqa

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.