UI Component Testing Guide

How to Test Faceted Filter UI with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing faceted filter UI with Playwright. URL state synchronization, checkbox combinations, live count updates, clear all behavior, mobile filter drawers, AND vs OR logic, and the pitfalls that break real filter test suites.

92%

According to the Baymard Institute, 92% of major e-commerce sites now offer faceted filtering, yet 42% of those implementations have significant usability issues that go undetected until production.

Baymard Institute E-Commerce UX Research, 2025

0Filter scenarios covered
0+Checkbox combinations per facet group
0sAvg debounce wait per filter
0%Fewer lines with Assrt

Faceted Filter Interaction Flow

UserFilter PanelURL / RouterAPI / BackendResults GridToggle checkboxUpdate query paramsFetch filtered resultsReturn matching itemsUpdate facet countsRender filtered resultsClick Clear AllRemove all filter params

1. Why Testing Faceted Filters Is Harder Than It Looks

Faceted filtering appears deceptively simple on the surface: a panel of checkboxes that narrow search results. But underneath, every checkbox toggle triggers a cascade of state changes that span the URL bar, the API layer, the results grid, the facet counts themselves, and often a “N results found” banner. Testing one checkbox in isolation is trivial. Testing the interaction between multiple checkboxes across multiple facet groups, where each selection changes the available counts in other groups, is where suites break down.

There are six structural reasons faceted filter UI is difficult to test reliably. First, URL state synchronization: every filter selection must update query parameters so that sharing a link or pressing the back button reproduces the exact filter state. Your test must verify the URL after every toggle, not just the visible results. Second, combinatorial explosion. A filter panel with four groups of four options each produces 256 possible checkbox combinations. You cannot test them all, so your suite must choose representative subsets intelligently.

Third, debounced or batched API calls. Most implementations delay the network request by 200 to 500 milliseconds after the last checkbox click to avoid firing on every intermediate state. Your test must wait for the debounce to settle and the API response to arrive before asserting on results. Fourth, live count updates. When you check “Red” under Color, the count next to “Small” under Size should update to reflect only red items that come in small. If the counts lag behind the results, users see stale data. Fifth, mobile responsiveness. On small viewports, faceted filters typically move into a slide-out drawer or bottom sheet with an “Apply” button, changing the interaction model entirely. Sixth, AND vs OR logic within and across facet groups. Most implementations use OR logic within a single group (selecting “Red” and “Blue” shows items that are red OR blue) and AND logic across groups (selecting “Red” under Color and “Small” under Size shows items that are red AND small). Getting this wrong is one of the most common filter bugs.

Faceted Filter State Cascade

🌐

Checkbox Toggle

User clicks a filter option

⚙️

State Update

Local filter state changes

↪️

URL Sync

Query params updated

🔒

Debounce Wait

200-500ms delay

🔔

API Request

Fetch filtered results

⚙️

Count Refresh

Facet counts recalculated

DOM Update

Results and counts render

2. Setting Up a Reliable Test Environment

Before writing any filter scenarios, you need deterministic test data. Faceted filter tests are meaningless if the product catalog changes between runs, because your assertions on result counts and facet counts will break every time inventory shifts. The most reliable approach is to seed a fixed dataset before each test run.

Project Setup
playwright.config.ts

Seeding Deterministic Test Data

Create a seed script that populates your catalog with a known set of products. Every product has explicit attributes for each facet group so you can predict exact counts in your assertions. For example, if you seed 20 products with 5 red items, 8 blue items, and 7 green items, your test can assert that the “Red” checkbox shows “(5)” next to it on initial load.

tests/fixtures/seed-products.ts

Test Environment Setup Flow

⚙️

Seed Database

Insert 20 fixed products

🌐

Start Dev Server

localhost:3000

Load Catalog Page

All 20 products visible

Verify Facet Counts

Red(5), Blue(8), Green(7)

3

Single Checkbox Selection and Result Update

Straightforward

3. Scenario: Single Checkbox Selection and Result Update

Goal: Verify that clicking a single filter checkbox narrows the results to only matching items, updates the result count banner, and visually marks the checkbox as checked.

Preconditions:The catalog page is loaded with all 20 seeded products visible. No filters are active. The result count banner reads “20 products.”

Playwright Implementation

tests/filters/single-checkbox.spec.ts

What to Assert Beyond the UI

The visible result count is necessary but not sufficient. You should also verify that the API request includes the correct query parameter. Intercept the network call and check that ?color=Red is present. This catches the common bug where the UI updates optimistically but the API request is malformed, leading to correct-looking results that are actually unfiltered.

tests/filters/single-checkbox-network.spec.ts
4

URL State Synchronization

Moderate

4. Scenario: URL State Synchronization

Goal: Verify that filter selections are persisted in URL query parameters so that sharing a filtered link reproduces the exact state, and that browser back/forward navigation restores previous filter states correctly.

Preconditions: The catalog page is loaded with no active filters. The URL has no query parameters.

Playwright Implementation

tests/filters/url-state-sync.spec.ts

URL State Sync: Playwright vs Assrt

test('filter state in URL survives navigation', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('checkbox', { name: 'Red' }).click();
  await page.waitForResponse((r) => r.url().includes('/api/products'));
  expect(page.url()).toContain('color=Red');
  await page.getByRole('checkbox', { name: 'Small' }).click();
  await page.waitForResponse((r) => r.url().includes('/api/products'));
  expect(page.url()).toContain('size=Small');
  await expect(page.getByTestId('result-count')).toHaveText('2 products');
  await page.goBack();
  await page.waitForResponse((r) => r.url().includes('/api/products'));
  await expect(page.getByTestId('result-count')).toHaveText('5 products');
});
17% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
5

Multi-Select Combinations and AND vs OR Logic

Complex

5. Scenario: Multi-Select Combinations and AND vs OR Logic

Goal: Verify that selecting multiple options within the same facet group applies OR logic (any match), while selecting options across different facet groups applies AND logic (all must match). This is the standard faceted search convention and the most frequently broken filter behavior.

Preconditions: All 20 seeded products are visible. No filters are active.

Playwright Implementation

tests/filters/and-or-logic.spec.ts

AND vs OR Logic Resolution

Filter PanelQuery BuilderDatabaseResultscolor=Red,Blue & size=SmallWHERE (color IN ('Red','Blue')) AND size='Small'5 matching productsUpdate counts per group
6

Live Facet Count Updates

Complex

6. Scenario: Live Facet Count Updates

Goal: Verify that when a filter is applied, the counts displayed next to other facet options update to reflect only the items in the current filtered set. This is the feature that distinguishes a well-built faceted search from a broken one.

Why this matters:When a user selects “Red” under Color, the count next to “Small” under Size should change from 8 (all small items) to 2 (only red items that are also small). Stale counts mislead users into clicking options that return zero results. According to the Baymard Institute, 36% of e-commerce sites fail to update facet counts contextually, leading to dead-end filtering experiences.

tests/filters/count-updates.spec.ts

Facet Count Updates: Playwright vs Assrt

test('facet counts update on filter', async ({ page }) => {
  await page.goto('/products');
  await expect(page.getByTestId('count-Small')).toHaveText('(8)');
  await page.getByRole('checkbox', { name: 'Red' }).click();
  await page.waitForResponse((r) => r.url().includes('/api/products'));
  await expect(page.getByTestId('count-Small')).toHaveText('(2)');
  await expect(page.getByTestId('count-Medium')).toHaveText('(2)');
  await expect(page.getByTestId('count-Large')).toHaveText('(1)');
  await expect(page.getByTestId('count-Blue')).toHaveText('(8)');
});
11% fewer lines
7

Clear All and Reset Behavior

Moderate

7. Scenario: Clear All and Reset Behavior

Goal:Verify that the “Clear All” button (or equivalent “Reset Filters” link) unchecks all checkboxes, removes all filter query parameters from the URL, resets the results to the full unfiltered catalog, and restores all facet counts to their original values. Also verify that per-group “Clear” links reset only that group.

Preconditions: Multiple filters are active across at least two facet groups.

tests/filters/clear-all.spec.ts
Test Run: Clear All Scenarios
8

Mobile Filter Drawer

Complex

8. Scenario: Mobile Filter Drawer

Goal:Verify that on mobile viewports, the faceted filter panel is hidden behind a “Filters” button, opens as a slide-out drawer or bottom sheet, allows the user to select filters, and applies them when the “Apply” or “Show Results” button is tapped. Also verify that the drawer closes after applying, and that the selected filter count badge on the trigger button updates correctly.

Why this is tricky:Mobile filter drawers introduce deferred application. On desktop, filters typically apply immediately as checkboxes are toggled. On mobile, the drawer collects selections and only fires the API call when the user taps “Apply.” This means your test must verify that intermediate checkbox clicks inside the drawer do NOT trigger result updates, and that results only change after the apply action.

tests/filters/mobile-drawer.spec.ts

Mobile Filter Drawer Flow

🌐

Tap Filters

Open drawer overlay

🔒

Select Options

Checkboxes toggled locally

⚙️

Preview Count

'Show N Results' button

Tap Apply

Fire API, close drawer

🌐

Results Update

Badge shows active count

Mobile Filter Drawer: Playwright vs Assrt

test.use(devices['iPhone 14']);

test('mobile filter drawer flow', async ({ page }) => {
  await page.goto('/products');
  await expect(page.getByTestId('filter-panel')).not.toBeVisible();
  await page.getByRole('button', { name: /Filters/i }).click();
  await expect(page.getByTestId('filter-drawer')).toBeVisible();
  await page.getByRole('checkbox', { name: 'Red' }).click();
  await page.getByRole('checkbox', { name: 'Small' }).click();
  await expect(page.getByTestId('result-count')).toHaveText('20 products');
  await page.getByRole('button', { name: /Show 2 Results/i }).click();
  await expect(page.getByTestId('filter-drawer')).not.toBeVisible();
  await expect(page.getByTestId('result-count')).toHaveText('2 products');
  await expect(page.getByTestId('filter-badge')).toHaveText('2');
});
15% fewer lines

9. Common Pitfalls That Break Faceted Filter Test Suites

These are real issues sourced from GitHub issues, Stack Overflow threads, and e-commerce QA discussions. If your filter test suite is flaky, one of these is probably the root cause.

Pitfalls to Avoid

  • Not waiting for debounce: Asserting on results immediately after clicking a checkbox, before the debounced API call fires. Fix: always waitForResponse after each checkbox click.
  • Asserting on stale counts: Checking facet counts before the response arrives. The DOM still shows old counts for a brief moment. Fix: wait for the API response, then use toHaveText with Playwright's auto-retry.
  • URL encoding mismatches: Expecting 'color=Red' but the app encodes it as 'color=red' or 'filters[color][]=Red'. Fix: parse with new URL() and normalize case before comparing.
  • Testing with live data: Using a real product catalog that changes between test runs. Counts shift, tests fail, and the team marks them as flaky. Fix: seed deterministic test data.
  • Ignoring the mobile drawer: Testing only the desktop filter panel. The mobile drawer has completely different interaction patterns (deferred apply, preview counts, dismiss behavior). Fix: add a separate mobile project in your Playwright config.
  • Race conditions on rapid clicks: Clicking three checkboxes in quick succession can cause earlier API responses to overwrite later ones. Fix: await each response individually, or test the final settled state after a longer waitForLoadState.
  • Forgetting browser back/forward: Most test suites check forward navigation but never test that pressing Back restores the previous filter state. Fix: add explicit goBack() assertions.
  • Hardcoding count values: Asserting that 'Small' shows '(8)' without understanding the seed data. When someone modifies the seed, every count assertion breaks. Fix: derive expected counts from the seed data programmatically.
Common Failure: Debounce Race Condition

Performance Considerations for Large Catalogs

Faceted filter tests on catalogs with thousands of products can be slow because each checkbox toggle triggers a backend query. Two strategies help. First, use API mocking for scenarios where you care about UI behavior (drawer open/close, checkbox state, URL sync) rather than backend correctness. Route interception with Playwright's page.route() lets you return instant responses with predictable data. Second, for integration tests that hit the real backend, run them in serial with a warm cache. Parallel execution with a shared database can cause flaky count assertions if two tests modify the seed data simultaneously.

tests/filters/mocked-api.spec.ts

10. Writing These Scenarios in Plain English with Assrt

Every scenario above required careful management of debounce waits, response interception, URL parsing, and DOM traversal. Assrt lets you write the same test logic in plain English. Each scenario block compiles to the same Playwright TypeScript shown in the previous sections, committed to your repo as real, runnable tests.

faceted-filters.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When the development team refactors the filter panel component, changes CSS class names, or switches from query parameter encoding to hash-based routing, Assrt detects the failures, analyzes the new DOM, and opens a pull request with the updated selectors. Your scenario files stay untouched.

Start with the single checkbox scenario. Once it is green in your CI, add URL state sync, then AND vs OR logic, then count updates, then clear all behavior, then the mobile drawer. In a single afternoon you can have complete faceted filter coverage that most production applications never manage to achieve by hand.

Faceted Filter Test Coverage Checklist

  • Single checkbox selection narrows results correctly
  • Filter selections persist in URL query parameters
  • Deep linking to a filtered URL restores full state
  • Browser back/forward restores previous filter states
  • OR logic within a facet group (Red + Blue = union)
  • AND logic across facet groups (Red + Small = intersection)
  • Facet counts update contextually on each filter change
  • Zero-count options are disabled or visually muted
  • Clear All resets checkboxes, results, URL, and counts
  • Per-group clear resets only that group
  • Mobile drawer collects selections before applying
  • Closing drawer without applying discards changes
  • API requests contain correct filter parameters

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk