Search and Filter Recipes
When to use: You need to test search inputs, filters (category, multi-select, date range), autocomplete suggestions, pagination with filters, or "no results" states.
Recipe 1: Search Input with Results
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("searches for a product and displays matching results", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
// Type a search query
await searchInput.fill("wireless keyboard")
// Submit the search (press Enter or click the search button)
await searchInput.press("Enter")
// Wait for results to load
await expect(
page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.first()
).toBeVisible()
// Verify results match the query
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
// Each result should contain the search term
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText(/wireless|keyboard/i)
}
// Verify the search query is preserved in the input
await expect(searchInput).toHaveValue("wireless keyboard")
// Verify result count is displayed
await expect(page.getByText(/\d+ results? found/i)).toBeVisible()
// Verify the URL reflects the search
await expect(page).toHaveURL(/[?&]q=wireless\+keyboard/)
})
test("search is case-insensitive", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
// Search with different cases
await searchInput.fill("WIRELESS KEYBOARD")
await searchInput.press("Enter")
const uppercaseResults = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
await searchInput.clear()
await searchInput.fill("wireless keyboard")
await searchInput.press("Enter")
const lowercaseResults = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
expect(uppercaseResults).toBe(lowercaseResults)
})
test("search preserves query on page reload", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("keyboard")
await searchInput.press("Enter")
await expect(page).toHaveURL(/[?&]q=keyboard/)
// Reload the page
await page.reload()
// Search input should still have the query
await expect(searchInput).toHaveValue("keyboard")
// Results should still be filtered
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("searches for a product and displays matching results", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("wireless keyboard")
await searchInput.press("Enter")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText(/wireless|keyboard/i)
}
await expect(searchInput).toHaveValue("wireless keyboard")
await expect(page).toHaveURL(/[?&]q=wireless\+keyboard/)
})Recipe 2: Search with Debounce (Waiting for Results)
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("debounced search triggers after user stops typing", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
const resultsList = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
// Track API calls to verify debounce behavior
let apiCallCount = 0
await page.route("**/api/products?*", async (route) => {
apiCallCount++
await route.continue()
})
// Type quickly (simulating real user typing)
await searchInput.pressSequentially("keyboard", { delay: 50 })
// Wait for debounce to settle and results to appear
// The debounce delay is typically 300-500ms after the last keystroke
await page.waitForResponse("**/api/products?*")
// Only one API call should have been made (debounced), not one per keystroke
expect(apiCallCount).toBeLessThanOrEqual(2) // Allow for at most 2 calls
// Verify results loaded
await expect(resultsList.first()).toBeVisible()
})
test("shows loading indicator during debounced search", async ({ page }) => {
// Slow down the search API to observe loading state
await page.route("**/api/products?*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.continue()
})
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("keyboard")
// Loading indicator should appear while waiting for results
await expect(page.getByRole("progressbar").or(page.getByText(/loading|searching/i))).toBeVisible()
// After results load, loading indicator disappears
await expect(
page.getByRole("progressbar").or(page.getByText(/loading|searching/i))
).not.toBeVisible({
timeout: 5000,
})
// Results should be visible
await expect(
page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.first()
).toBeVisible()
})
test("clears search results when input is emptied", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
// Search for something specific
await searchInput.fill("keyboard")
await searchInput.press("Enter")
const filteredCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Clear the search
await searchInput.clear()
// Wait for results to reset (debounce + API call)
await page.waitForResponse("**/api/products?*")
const unfilteredCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Unfiltered should have more or equal results
expect(unfilteredCount).toBeGreaterThanOrEqual(filteredCount)
})JavaScript
const { test, expect } = require("@playwright/test")
test("debounced search triggers after user stops typing", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
let apiCallCount = 0
await page.route("**/api/products?*", async (route) => {
apiCallCount++
await route.continue()
})
await searchInput.pressSequentially("keyboard", { delay: 50 })
await page.waitForResponse("**/api/products?*")
expect(apiCallCount).toBeLessThanOrEqual(2)
})
test("shows loading indicator during debounced search", async ({ page }) => {
await page.route("**/api/products?*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.continue()
})
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("keyboard")
await expect(page.getByRole("progressbar").or(page.getByText(/loading|searching/i))).toBeVisible()
await expect(
page.getByRole("progressbar").or(page.getByText(/loading|searching/i))
).not.toBeVisible({
timeout: 5000,
})
})Recipe 3: Filter by Category
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("filters products by a single category", async ({ page }) => {
await page.goto("/products")
// Get total count before filtering
const totalRows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const totalCount = await totalRows.count()
// Select a category filter
await page.getByLabel("Category").selectOption("Electronics")
// Wait for filtered results
await page.waitForResponse("**/api/products?*")
// Verify fewer results (or at minimum, a different set)
const filteredRows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const filteredCount = await filteredRows.count()
expect(filteredCount).toBeLessThanOrEqual(totalCount)
expect(filteredCount).toBeGreaterThan(0)
// Verify all results belong to the selected category
for (let i = 0; i < filteredCount; i++) {
await expect(filteredRows.nth(i)).toContainText("Electronics")
}
// Verify URL updated
await expect(page).toHaveURL(/[?&]category=electronics/i)
// Verify active filter is displayed
await expect(
page
.getByText(/filtered by.*electronics/i)
.or(page.locator('[data-testid="active-filter"]').filter({ hasText: "Electronics" }))
).toBeVisible()
})
test("filters by category using sidebar checkboxes", async ({ page }) => {
await page.goto("/products")
// Click a category checkbox in the sidebar
const sidebar = page.getByRole("complementary")
await sidebar.getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText("Electronics")
}
})
test("changing category replaces previous filter", async ({ page }) => {
await page.goto("/products")
// Select first category
await page.getByLabel("Category").selectOption("Electronics")
await page.waitForResponse("**/api/products?*")
const electronicsCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Change to a different category
await page.getByLabel("Category").selectOption("Clothing")
await page.waitForResponse("**/api/products?*")
const clothingCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Results should be different
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
for (let i = 0; i < clothingCount; i++) {
await expect(rows.nth(i)).toContainText("Clothing")
await expect(rows.nth(i)).not.toContainText("Electronics")
}
})JavaScript
const { test, expect } = require("@playwright/test")
test("filters products by a single category", async ({ page }) => {
await page.goto("/products")
const totalRows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const totalCount = await totalRows.count()
await page.getByLabel("Category").selectOption("Electronics")
await page.waitForResponse("**/api/products?*")
const filteredRows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const filteredCount = await filteredRows.count()
expect(filteredCount).toBeLessThanOrEqual(totalCount)
for (let i = 0; i < filteredCount; i++) {
await expect(filteredRows.nth(i)).toContainText("Electronics")
}
await expect(page).toHaveURL(/[?&]category=electronics/i)
})Recipe 4: Multi-Select Filter
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("filters by multiple categories simultaneously", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
// Select multiple category checkboxes
await sidebar.getByLabel("Electronics").check()
await sidebar.getByLabel("Clothing").check()
// Wait for results to update
await page.waitForResponse("**/api/products?*")
// Verify results include items from both categories
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
// Collect all categories from results
const categories = new Set<string>()
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).textContent()
if (text?.includes("Electronics")) categories.add("Electronics")
if (text?.includes("Clothing")) categories.add("Clothing")
}
// Should have results from both categories
expect(categories.has("Electronics")).toBe(true)
expect(categories.has("Clothing")).toBe(true)
// URL should reflect both filters
await expect(page).toHaveURL(/category=electronics/i)
await expect(page).toHaveURL(/category=clothing/i)
// Active filter chips should be visible
await expect(
page.getByRole("button", { name: /Electronics.*×|Remove Electronics/i })
).toBeVisible()
await expect(page.getByRole("button", { name: /Clothing.*×|Remove Clothing/i })).toBeVisible()
})
test("removes one filter from a multi-select filter", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
// Apply two filters
await sidebar.getByLabel("Electronics").check()
await sidebar.getByLabel("Clothing").check()
await page.waitForResponse("**/api/products?*")
const bothCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Remove one filter by clicking the filter chip's remove button
await page.getByRole("button", { name: /Clothing.*×|Remove Clothing/i }).click()
await page.waitForResponse("**/api/products?*")
// Verify only Electronics results remain
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const electronicsOnlyCount = await rows.count()
expect(electronicsOnlyCount).toBeLessThanOrEqual(bothCount)
for (let i = 0; i < electronicsOnlyCount; i++) {
await expect(rows.nth(i)).toContainText("Electronics")
}
// Clothing checkbox should be unchecked
await expect(sidebar.getByLabel("Clothing")).not.toBeChecked()
await expect(sidebar.getByLabel("Electronics")).toBeChecked()
})
test("multi-select dropdown filter", async ({ page }) => {
await page.goto("/products")
// Open the multi-select dropdown
await page.getByRole("combobox", { name: "Tags" }).click()
// Select multiple options from the dropdown
await page.getByRole("option", { name: "Featured" }).click()
await page.getByRole("option", { name: "On Sale" }).click()
// Close the dropdown
await page.keyboard.press("Escape")
// Verify selected values displayed
await expect(page.getByText("Featured")).toBeVisible()
await expect(page.getByText("On Sale")).toBeVisible()
// Wait for results
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("filters by multiple categories simultaneously", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
await sidebar.getByLabel("Electronics").check()
await sidebar.getByLabel("Clothing").check()
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
await expect(page).toHaveURL(/category=electronics/i)
await expect(page).toHaveURL(/category=clothing/i)
})
test("removes one filter from a multi-select filter", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
await sidebar.getByLabel("Electronics").check()
await sidebar.getByLabel("Clothing").check()
await page.waitForResponse("**/api/products?*")
await page.getByRole("button", { name: /Clothing.*×|Remove Clothing/i }).click()
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText("Electronics")
}
})Recipe 5: Date Range Filter
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("filters records by date range", async ({ page }) => {
await page.goto("/orders")
// Fill in the date range inputs
await page.getByLabel("From date").fill("2024-01-01")
await page.getByLabel("To date").fill("2024-03-31")
// Apply the filter
await page.getByRole("button", { name: "Apply filter" }).click()
// Wait for filtered results
await page.waitForResponse("**/api/orders?*")
// Verify all results fall within the date range
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
// Check that each row's date is within the range
for (let i = 0; i < count; i++) {
const dateCell = rows.nth(i).getByRole("cell").nth(3) // Assuming date is the 4th column
const dateText = await dateCell.textContent()
const date = new Date(dateText!.trim())
expect(date.getTime()).toBeGreaterThanOrEqual(new Date("2024-01-01").getTime())
expect(date.getTime()).toBeLessThanOrEqual(new Date("2024-03-31T23:59:59").getTime())
}
// Verify URL updated with date params
await expect(page).toHaveURL(/from=2024-01-01/)
await expect(page).toHaveURL(/to=2024-03-31/)
})
test("filters using a date picker component", async ({ page }) => {
await page.goto("/orders")
// Open the date picker
await page.getByRole("button", { name: /date range|calendar/i }).click()
// Navigate to January 2024
const calendar = page.getByRole("dialog")
await expect(calendar).toBeVisible()
// Select the month/year via navigation
while (!(await calendar.getByText("January 2024").isVisible())) {
await calendar.getByRole("button", { name: /previous month/i }).click()
}
// Click start date
await calendar.getByRole("gridcell", { name: "15" }).click()
// Click end date
await calendar.getByRole("gridcell", { name: "28" }).click()
// Apply
await calendar.getByRole("button", { name: "Apply" }).click()
await expect(calendar).not.toBeVisible()
// Verify the date range is displayed
await expect(page.getByText(/Jan 15.*Jan 28/i)).toBeVisible()
await page.waitForResponse("**/api/orders?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})
test("shows error for invalid date range", async ({ page }) => {
await page.goto("/orders")
// Set end date before start date
await page.getByLabel("From date").fill("2024-06-01")
await page.getByLabel("To date").fill("2024-01-01")
await page.getByRole("button", { name: "Apply filter" }).click()
await expect(page.getByText(/end date.*before.*start date|invalid.*range/i)).toBeVisible()
})
test("uses preset date ranges", async ({ page }) => {
await page.goto("/orders")
// Click a preset date range shortcut
await page.getByRole("button", { name: "Last 30 days" }).click()
await page.waitForResponse("**/api/orders?*")
// Verify the from date input reflects approximately 30 days ago
const fromValue = await page.getByLabel("From date").inputValue()
const fromDate = new Date(fromValue)
const now = new Date()
const diffDays = Math.round((now.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24))
expect(diffDays).toBeGreaterThanOrEqual(29)
expect(diffDays).toBeLessThanOrEqual(31)
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("filters records by date range", async ({ page }) => {
await page.goto("/orders")
await page.getByLabel("From date").fill("2024-01-01")
await page.getByLabel("To date").fill("2024-03-31")
await page.getByRole("button", { name: "Apply filter" }).click()
await page.waitForResponse("**/api/orders?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
expect(count).toBeGreaterThan(0)
await expect(page).toHaveURL(/from=2024-01-01/)
await expect(page).toHaveURL(/to=2024-03-31/)
})
test("uses preset date ranges", async ({ page }) => {
await page.goto("/orders")
await page.getByRole("button", { name: "Last 30 days" }).click()
await page.waitForResponse("**/api/orders?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})Recipe 6: Clear All Filters
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("clears all active filters at once", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
// Apply multiple filters
await sidebar.getByLabel("Electronics").check()
await page.getByLabel("Min price").fill("50")
await page.getByLabel("Max price").fill("200")
await page.getByRole("searchbox", { name: /search/i }).fill("keyboard")
await page.getByRole("searchbox", { name: /search/i }).press("Enter")
// Wait for filtered results
await page.waitForResponse("**/api/products?*")
const filteredCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
// Verify filters are active
await expect(page.getByText(/\d+ active filter/i)).toBeVisible()
// Click "Clear all filters"
await page.getByRole("button", { name: /clear all|reset filters/i }).click()
// Wait for unfiltered results
await page.waitForResponse("**/api/products?*")
// Verify all filters are reset
await expect(sidebar.getByLabel("Electronics")).not.toBeChecked()
await expect(page.getByLabel("Min price")).toHaveValue("")
await expect(page.getByLabel("Max price")).toHaveValue("")
await expect(page.getByRole("searchbox", { name: /search/i })).toHaveValue("")
// Verify more results are shown
const unfilteredCount = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.count()
expect(unfilteredCount).toBeGreaterThanOrEqual(filteredCount)
// Verify URL is clean
await expect(page).toHaveURL("/products")
// Verify "active filters" indicator is gone
await expect(page.getByText(/active filter/i)).not.toBeVisible()
})
test("clear all button only appears when filters are active", async ({ page }) => {
await page.goto("/products")
// No filters active -- clear button should not be visible
await expect(page.getByRole("button", { name: /clear all|reset filters/i })).not.toBeVisible()
// Apply a filter
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
// Clear button should now be visible
await expect(page.getByRole("button", { name: /clear all|reset filters/i })).toBeVisible()
// Remove the filter manually
await page.getByRole("complementary").getByLabel("Electronics").uncheck()
await page.waitForResponse("**/api/products?*")
// Clear button should disappear again
await expect(page.getByRole("button", { name: /clear all|reset filters/i })).not.toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("clears all active filters at once", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
await sidebar.getByLabel("Electronics").check()
await page.getByLabel("Min price").fill("50")
await page.getByLabel("Max price").fill("200")
await page.getByRole("searchbox", { name: /search/i }).fill("keyboard")
await page.getByRole("searchbox", { name: /search/i }).press("Enter")
await page.waitForResponse("**/api/products?*")
await page.getByRole("button", { name: /clear all|reset filters/i }).click()
await page.waitForResponse("**/api/products?*")
await expect(sidebar.getByLabel("Electronics")).not.toBeChecked()
await expect(page.getByLabel("Min price")).toHaveValue("")
await expect(page.getByLabel("Max price")).toHaveValue("")
await expect(page.getByRole("searchbox", { name: /search/i })).toHaveValue("")
await expect(page).toHaveURL("/products")
})Recipe 7: Search with Autocomplete / Suggestions
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("shows autocomplete suggestions while typing", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
// Type a partial query
await searchInput.fill("key")
// Suggestions dropdown should appear
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
// Verify suggestions contain the typed text
const options = suggestions.getByRole("option")
const count = await options.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await expect(options.nth(i)).toContainText(/key/i)
}
})
test("selects a suggestion with click", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
// Click a specific suggestion
await suggestions.getByRole("option", { name: /Wireless Keyboard/i }).click()
// Suggestions should close
await expect(suggestions).not.toBeVisible()
// Search input should contain the selected suggestion
await expect(searchInput).toHaveValue("Wireless Keyboard")
// Results should be filtered to the selected item
await expect(page.getByRole("row", { name: /Wireless Keyboard/i })).toBeVisible()
})
test("navigates suggestions with keyboard", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
// Navigate down with arrow keys
await searchInput.press("ArrowDown")
const firstOption = suggestions.getByRole("option").first()
await expect(firstOption).toHaveAttribute("aria-selected", "true")
await searchInput.press("ArrowDown")
const secondOption = suggestions.getByRole("option").nth(1)
await expect(secondOption).toHaveAttribute("aria-selected", "true")
await expect(firstOption).toHaveAttribute("aria-selected", "false")
// Select with Enter
await searchInput.press("Enter")
// Suggestions should close and value should be selected
await expect(suggestions).not.toBeVisible()
const selectedText = await searchInput.inputValue()
expect(selectedText.length).toBeGreaterThan(3) // More than just "key"
})
test("closes suggestions with Escape key", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
await searchInput.press("Escape")
await expect(suggestions).not.toBeVisible()
// Input should retain the typed text
await expect(searchInput).toHaveValue("key")
})
test("shows recent searches when input is focused", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
// Focus the input without typing
await searchInput.click()
// Should show recent searches or popular suggestions
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
await expect(suggestions.getByText(/recent|popular/i)).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("shows autocomplete suggestions while typing", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
const options = suggestions.getByRole("option")
const count = await options.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await expect(options.nth(i)).toContainText(/key/i)
}
})
test("selects a suggestion with click", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await suggestions.getByRole("option", { name: /Wireless Keyboard/i }).click()
await expect(suggestions).not.toBeVisible()
await expect(searchInput).toHaveValue("Wireless Keyboard")
})
test("navigates suggestions with keyboard", async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("combobox", { name: /search/i })
await searchInput.fill("key")
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
await searchInput.press("ArrowDown")
await searchInput.press("ArrowDown")
await searchInput.press("Enter")
await expect(suggestions).not.toBeVisible()
})Recipe 8: No Results State
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test('shows "no results" state for unmatched search', async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("xyznonexistentproduct123")
await searchInput.press("Enter")
// Wait for the API response
await page.waitForResponse("**/api/products?*")
// Verify no results message is displayed
await expect(page.getByText(/no results|no products found|nothing found/i)).toBeVisible()
// Table/list should be empty (no data rows)
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows).toHaveCount(0)
// Verify helpful suggestions are shown
await expect(page.getByText(/try different|broaden your search|check spelling/i)).toBeVisible()
// Verify a "clear search" link or button is available
await expect(
page
.getByRole("button", { name: /clear search|show all/i })
.or(page.getByRole("link", { name: /clear search|show all/i }))
).toBeVisible()
})
test("no results state with active filters offers to clear them", async ({ page }) => {
await page.goto("/products")
// Apply a very restrictive filter combination
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.getByLabel("Min price").fill("99999")
await page.getByRole("button", { name: "Apply filter" }).click()
await page.waitForResponse("**/api/products?*")
// Verify the no results state mentions the active filters
await expect(page.getByText(/no results|no products found/i)).toBeVisible()
await expect(page.getByRole("button", { name: /clear filters|reset/i })).toBeVisible()
// Click clear filters and verify results appear
await page.getByRole("button", { name: /clear filters|reset/i }).click()
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
expect(await rows.count()).toBeGreaterThan(0)
})
test("no results state illustration is displayed", async ({ page }) => {
await page.goto("/products")
await page.getByRole("searchbox", { name: /search/i }).fill("zzzznotfound")
await page.getByRole("searchbox", { name: /search/i }).press("Enter")
await page.waitForResponse("**/api/products?*")
// Verify an illustration or icon is shown
await expect(
page
.getByRole("img", { name: /no results|empty/i })
.or(page.locator('[data-testid="empty-state-illustration"]'))
).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test('shows "no results" state for unmatched search', async ({ page }) => {
await page.goto("/products")
const searchInput = page.getByRole("searchbox", { name: /search/i })
await searchInput.fill("xyznonexistentproduct123")
await searchInput.press("Enter")
await page.waitForResponse("**/api/products?*")
await expect(page.getByText(/no results|no products found|nothing found/i)).toBeVisible()
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows).toHaveCount(0)
await expect(page.getByText(/try different|broaden your search/i)).toBeVisible()
})
test("no results state with filters offers to clear them", async ({ page }) => {
await page.goto("/products")
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.getByLabel("Min price").fill("99999")
await page.getByRole("button", { name: "Apply filter" }).click()
await page.waitForResponse("**/api/products?*")
await expect(page.getByText(/no results|no products found/i)).toBeVisible()
await page.getByRole("button", { name: /clear filters|reset/i }).click()
await page.waitForResponse("**/api/products?*")
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
expect(await rows.count()).toBeGreaterThan(0)
})Recipe 9: Pagination with Filters
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("pagination resets to page 1 when a filter is applied", async ({ page }) => {
await page.goto("/products")
const pagination = page.getByRole("navigation", { name: /pagination/i })
// Navigate to page 3
await pagination.getByRole("button", { name: "3" }).click()
await expect(page).toHaveURL(/page=3/)
// Apply a filter
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
// Should reset to page 1
await expect(page).toHaveURL(/page=1|(?!.*page=)/)
await expect(pagination.getByRole("button", { name: "1" })).toHaveAttribute(
"aria-current",
"page"
)
})
test("filters persist across pagination", async ({ page }) => {
await page.goto("/products")
// Apply a filter
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
// Get page 1 results
const page1FirstItem = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.first()
.textContent()
// Navigate to page 2
const pagination = page.getByRole("navigation", { name: /pagination/i })
await pagination.getByRole("button", { name: "2" }).click()
await page.waitForResponse("**/api/products?*")
// Verify the filter is still applied (URL has both params)
await expect(page).toHaveURL(/category=electronics/i)
await expect(page).toHaveURL(/page=2/)
// Verify checkbox is still checked
await expect(page.getByRole("complementary").getByLabel("Electronics")).toBeChecked()
// Verify page 2 data is different from page 1
const page2FirstItem = await page
.getByRole("row")
.filter({ hasNot: page.getByRole("columnheader") })
.first()
.textContent()
expect(page2FirstItem).not.toBe(page1FirstItem)
// All results on page 2 should still match the filter
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText("Electronics")
}
})
test("total result count updates with filters", async ({ page }) => {
await page.goto("/products")
// Get unfiltered total
const totalText = await page.getByText(/showing.*of \d+/i).textContent()
const totalMatch = totalText?.match(/of (\d+)/)
const totalCount = parseInt(totalMatch?.[1] || "0")
// Apply a filter
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
// Total count should be less or equal
const filteredText = await page.getByText(/showing.*of \d+/i).textContent()
const filteredMatch = filteredText?.match(/of (\d+)/)
const filteredCount = parseInt(filteredMatch?.[1] || "0")
expect(filteredCount).toBeLessThanOrEqual(totalCount)
// Number of pagination buttons may also decrease
const paginationButtons = page
.getByRole("navigation", { name: /pagination/i })
.getByRole("button")
.filter({ hasNotText: /previous|next|prev|›|‹/i })
const pageCount = await paginationButtons.count()
expect(pageCount).toBeGreaterThan(0)
})
test("deep link with filters and page works correctly", async ({ page }) => {
// Navigate directly to a filtered, paginated URL
await page.goto("/products?category=electronics&q=wireless&page=2")
// Verify all filter controls reflect the URL state
await expect(page.getByRole("complementary").getByLabel("Electronics")).toBeChecked()
await expect(page.getByRole("searchbox", { name: /search/i })).toHaveValue("wireless")
const pagination = page.getByRole("navigation", { name: /pagination/i })
await expect(pagination.getByRole("button", { name: "2" })).toHaveAttribute(
"aria-current",
"page"
)
// Verify results match the filters
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("pagination resets to page 1 when a filter is applied", async ({ page }) => {
await page.goto("/products")
const pagination = page.getByRole("navigation", { name: /pagination/i })
await pagination.getByRole("button", { name: "3" }).click()
await expect(page).toHaveURL(/page=3/)
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
await expect(page).toHaveURL(/page=1|(?!.*page=)/)
})
test("filters persist across pagination", async ({ page }) => {
await page.goto("/products")
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
const pagination = page.getByRole("navigation", { name: /pagination/i })
await pagination.getByRole("button", { name: "2" }).click()
await page.waitForResponse("**/api/products?*")
await expect(page).toHaveURL(/category=electronics/i)
await expect(page).toHaveURL(/page=2/)
await expect(page.getByRole("complementary").getByLabel("Electronics")).toBeChecked()
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
const count = await rows.count()
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText("Electronics")
}
})
test("deep link with filters and page works correctly", async ({ page }) => {
await page.goto("/products?category=electronics&q=wireless&page=2")
await expect(page.getByRole("complementary").getByLabel("Electronics")).toBeChecked()
await expect(page.getByRole("searchbox", { name: /search/i })).toHaveValue("wireless")
const pagination = page.getByRole("navigation", { name: /pagination/i })
await expect(pagination.getByRole("button", { name: "2" })).toHaveAttribute(
"aria-current",
"page"
)
})Variations
Search with Highlight of Matching Terms
test("highlights matching terms in search results", async ({ page }) => {
await page.goto("/products")
await page.getByRole("searchbox", { name: /search/i }).fill("wireless")
await page.getByRole("searchbox", { name: /search/i }).press("Enter")
await page.waitForResponse("**/api/products?*")
// Verify matching text is wrapped in a highlight element
const highlights = page.locator("mark, .highlight, [data-highlight]")
const count = await highlights.count()
expect(count).toBeGreaterThan(0)
// Each highlight should contain the search term
for (let i = 0; i < count; i++) {
await expect(highlights.nth(i)).toContainText(/wireless/i)
}
})Search with URL Query Params for Sharing
test("search results are shareable via URL", async ({ page, context }) => {
await page.goto("/products")
await page.getByRole("searchbox", { name: /search/i }).fill("keyboard")
await page.getByRole("complementary").getByLabel("Electronics").check()
await page.getByRole("searchbox", { name: /search/i }).press("Enter")
await page.waitForResponse("**/api/products?*")
// Get the current URL
const searchUrl = page.url()
// Open the same URL in a new page (simulating sharing the link)
const newPage = await context.newPage()
await newPage.goto(searchUrl)
// Verify the same filters and results are shown
await expect(newPage.getByRole("searchbox", { name: /search/i })).toHaveValue("keyboard")
await expect(newPage.getByRole("complementary").getByLabel("Electronics")).toBeChecked()
const rows = newPage.getByRole("row").filter({ hasNot: newPage.getByRole("columnheader") })
await expect(rows.first()).toBeVisible()
await newPage.close()
})Faceted Search with Result Counts
test("filter options show result counts", async ({ page }) => {
await page.goto("/products")
const sidebar = page.getByRole("complementary")
// Verify each filter option shows a count
await expect(sidebar.getByText(/Electronics \(\d+\)/)).toBeVisible()
await expect(sidebar.getByText(/Clothing \(\d+\)/)).toBeVisible()
await expect(sidebar.getByText(/Books \(\d+\)/)).toBeVisible()
// Apply a filter and verify counts update
await sidebar.getByLabel("Electronics").check()
await page.waitForResponse("**/api/products?*")
// Other category counts should update to reflect cross-filtering
const clothingText = await sidebar.getByText(/Clothing \(\d+\)/).textContent()
const clothingCount = parseInt(clothingText?.match(/\((\d+)\)/)?.[1] || "0")
// The counts should reflect what would happen if that filter were added
expect(clothingCount).toBeGreaterThanOrEqual(0)
})Tips
Always verify the URL reflects the filter state. Search queries, category selections, date ranges, and pagination should all be encoded in query parameters. This ensures deep linking, bookmarking, and browser back/forward work correctly. If your app does not do this, file a bug.
Use
waitForResponseinstead of arbitrary waits. After applying a filter or search, wait for the specific API call to complete:await page.waitForResponse('**/api/products?*'). This is deterministic and faster thanwaitForTimeout.Test filter combinations, not just individual filters. Users apply multiple filters simultaneously -- category + price range + search term. Test that filters compose correctly and that the resulting URL contains all parameters.
Verify the controls reflect the state, not just the results. After filtering, check that the checkbox is checked, the dropdown shows the right value, and the search input still contains the query. After clearing, verify they all reset. This catches one-way binding bugs.
Test deep links for every filter combination. Paste a URL with filter parameters directly (
/products?category=electronics&q=keyboard&page=2) and verify the page renders correctly. This catches bugs where filters only work when set via the UI but not from URL params.
Related
recipes/crud-testing.md-- Filter and search within CRUD resource listsfoundations/assertions.md-- Assertion patterns for dynamic contentpatterns/page-objects.md-- Encapsulate search and filter controlsfoundations/waiting.md-- Waiting strategies for async search results