How to Test Search, Filters, and Faceted Navigation
Short answer
Search and faceted UIs fail on URL desync, stale counts, and combinatorial filter bugs—not missing search boxes. Seed a catalog with known tags per dimension, assert query params and probe result counts, and prioritize filter combinations by prod usage via TrueCoverage rather than exhaustive Cartesian products.
Part of Testing Guides by UI patterns.
Who this is for
Teams building catalog, admin, analytics, or marketplace UIs with search boxes, facet chips, sort dropdowns, and sharable filtered URLs. Typical stacks: Algolia/Elastic frontends, REST list APIs with query strings, React Query + URL state libraries (nuqs, React Router search params).
Why testing search and filters matters
Filter bugs hide revenue and break trust:
- Hidden inventory — wrong facet logic excludes in-stock products; marketing campaigns point to empty result pages.
- Broken deep links — shared URLs drop filters on refresh; sales teams cannot reproduce customer views.
- Wrong analytics — admin filters export incorrect subsets; compliance reports omit regulated transactions.
- Performance incidents — unbounded filter combos trigger slow queries only in prod with real catalog size.
- Accessibility gaps — facet counts update without live region announcements; screen reader users apply filters blindly.
Tests must assert URL state, result cardinality, and probe/API agreement—not that a checkbox toggles.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| URL sync | Refresh drops filters | Shared links broken | Assert page.url() query params |
| Zero results | Empty state vs error | Flaky skeleton UI | Probe total=0 + empty state role |
| Multi-facet AND | Over-restrictive logic | False empty | Seed item matching all facets |
| Multi-facet OR | Under-restrictive logic | Too many rows | Probe count decreases monotonically |
| Sort + filter | Sort resets filter | User confusion | Apply both; assert params persist |
| Pagination + filter | Page 2 invalid after filter | 404 or duplicate rows | Reset page index on filter change |
| Full-text + facet | Search narrows wrong set | Index lag | Probe IDs in result set |
| Clear all | Partial clear | Stale chips | Assert all params removed |
| Debounced search | Typeahead races | Wrong final query | pressSequentially + wait for response |
| Mobile drawer filters | Desktop-only selectors | Untested path | Separate viewport spec or component probe |
| Saved searches | Persist user prefs | Cross-user leak | Probe scoped to user id |
| Special characters | & in query | Broken URL | Encode and assert round-trip |
Seed catalog pattern
Arrange a deterministic catalog via test seed route—never rely on prod-like random data:
// POST /api/test/seed-catalog
// Body: { runId, items: [{ id, tags: { color: 'red', size: 'M' }, price: 100 }] }
await request.post('/api/test/seed-catalog', {
data: { runId, items: catalogFixtures },
});
Each fixture item should be tagged so one unique combination exists for your highest-priority facet pair (e.g., red + size M → exactly 3 SKUs).
URL and probe Assert pattern
await page.getByRole('checkbox', { name: 'Red' }).check();
await page.getByRole('checkbox', { name: 'Medium' }).check();
await expect(page).toHaveURL(/color=red/);
await expect(page).toHaveURL(/size=M/);
const probe = await request.get(`/api/test/catalog-count?runId=${runId}&color=red&size=M`);
expect(await probe.json()).toEqual({ total: 3, ids: expect.arrayContaining(['sku-1']) });
UI list length is optional when virtual scroll hides rows—probe is authoritative.
Prioritizing filter combinations
Combinations explode (2^n facets). Use TrueCoverage dimensions:
filter_combo— normalized facet key (e.g.,color:red|size:M)sort_order—price_asc,newest,relevance
Compare prod search/filter event distributions to test runs. When prod shows filter_combo=brand:x|category:y at 18% traffic but tests never exercise it, run /testchimp evolve to add a SmartTest—link with // @Scenario: in markdown plans (requirement traceability).
Zero results and error states
await page.getByLabel('Search').fill('zzzz-no-match-' + runId);
await page.keyboard.press('Enter');
await expect(page.getByRole('status')).toContainText(/no results/i);
const probe = await request.get(`/api/test/catalog-count?q=zzzz-no-match-${runId}`);
expect((await probe.json()).total).toBe(0);
Distinguish empty catalog (seed failure) from valid zero results via probe.
Debounced search
const responsePromise = page.waitForResponse(r =>
r.url().includes('/api/search') && r.status() === 200
);
await page.getByLabel('Search').pressSequentially('desk', { delay: 50 });
await responsePromise;
await expect(page.getByTestId('result-row')).toHaveCount(await probeCount('desk'));
Avoid typing with fill() when debounce listens to individual keystrokes.
CI checklist
- Seed catalog per worker with unique
runIdprefix on SKUs - Assert URL params after every filter mutation
- Probe result counts for at least top 3 prod
filter_comboslices - Test clear-all and browser back/forward if SPA uses history API
- Do not assert exact product card copy—assert IDs or probe list
- Run ExploreChimp on mobile filter drawer paths for layout regressions
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Test every facet permutation | Unmaintainable suite | TrueCoverage-prioritized combos |
| Assert visible row count only | Virtual scroll hides rows | Probe API count |
| Ignore URL params | Refresh breaks sharing | Assert query string |
| Shared catalog in parallel CI | Cross-test pollution | Per-run seed |
waitForTimeout after filter | Race with network | waitForResponse or poll probe |
| Skip zero-results path | SEO pages show errors | Explicit empty state spec |
Example scenario
Situation: User applies color=red and size=M, copies URL, opens in new tab.
Expected outcome: Same three products; filters pre-selected; probe count matches.
Why UI-only automation breaks: Chips show red+M but API returns unfiltered list—new tab shows all items.
- Arrange: Seed catalog where only three SKUs match red+M for runId.
- Act: Apply filters, copy URL, open in fresh context without cookies.
- Assert: Probe count=3; URL params intact; facet checkboxes checked; result IDs match probe.
TestChimp workflow: Instrument filter_apply events with filter_combo and sort_order; expand tests when prod combo exceeds test coverage.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Infinite scroll — filtered feeds that paginate
- Data grids — table filters and bulk actions
- Real estate listings — map + list filter sync
- CSV import/export — export filtered subsets
External references
Frequently asked questions
Which filter combinations should I test first?
Compare prod vs test-run across filter_combo and sort_order in TrueCoverage. Prioritize combos with high prod traffic—not every Cartesian product. Run /testchimp evolve when gaps appear.
How do I test filters with virtual scrolling?
Do not count DOM rows. Probe API total and assert a known result ID appears via search-in-grid helper or scroll-into-view only for spot checks.
Should filtered state live in the URL?
If your product supports shareable searches, E2E must assert query params survive refresh and new-tab open. Probe should accept same params as UI.
How do I avoid flaky debounced search tests?
Use pressSequentially with waitForResponse on the search API, or poll probe until count stabilizes. Avoid fixed timeouts.
How do saved searches differ from URL filters?
Saved searches persist server-side per user—probe that another user cannot load saved filter IDs; URL tests alone miss ACL bugs.
Can ExploreChimp help faceted navigation?
Yes—run exploratory paths on mobile filter drawers and RTL locales where chips overflow; convert UX breaks to SmartTests with seeded catalogs.
How do I test zero results vs API errors?
Seed catalog known to miss query; probe total=0. Separate spec stubs 500 on search API and asserts error banner—not empty state.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.