skip to content

Filtering and Chaining

Trimming a matched set with filter, scoping a search inside a container, combining two locators, and reading a whole set back. Asked because this is the API half of narrowing.

on this pageshow

explore

questions

5

In Playwright, what does locator.filter({ hasText: 'Blocked' }) return compared with the locator it was called on?

level: juniorimportance: must knowfreq 78%

answer

  1. Narrows the set, never descends into it
  2. Returns a new locator, original untouched
  3. Text searched across the whole subtree
  4. Strings match case-insensitively as substrings
  5. RegExp when you need anchoring

basics

~20 s

A new locator matching the same kind of elements, minus the ones whose subtree lacks that text. The original locator is untouched, the match stays on the outer elements, and a string is matched case-insensitively as a substring.

solid answer

~40 s

`locator.filter()` narrows an existing match rather than searching deeper. Given `page.getByTestId('issue-card')` matching every card on a board, `.filter({ hasText: 'Blocked' })` returns a new locator that still points at cards — only the ones whose own text or a descendant's text contains `Blocked`. Locators are immutable descriptions, so the original stays usable and you can chain `.filter()` repeatedly to stack conditions. A string argument is matched case-insensitively as a substring against the element's whole rendered subtree, so `'blocked'` also matches `Blocked by #412`. Pass a `RegExp` when you need anchoring or case sensitivity, as in `filter({ hasText: /^Blocked$/ })`. Nothing is queried at this point: filtering only builds a description, and the DOM is consulted when you act on or assert against the locator.

code

typescript · 14 lines
typescript
const cards = page.getByTestId('issue-card');

// Same element type, fewer of them: still cards, not the pill inside.
const blocked = cards.filter({ hasText: 'Blocked' });

// Case-insensitive substring: matches "Blocked by #412".
await expect(blocked).toHaveCount(2);

// Anchored and case-sensitive: only a card whose text is exactly "Blocked".
const exactlyBlocked = cards.filter({ hasText: /^Blocked$/ });

// Chained filters intersect.
const blockedNotDraft = blocked.filter({ hasNotText: 'Draft' });
await blockedNotDraft.first().click();

go deeper

for a junior

Remember the shape: filter takes a set of elements and gives back a smaller set of the same elements. It does not reach inside them, and it does not change the locator you started from.

for a middle

Be able to explain that a string hasText is a case-insensitive substring match over the element's whole subtree, that a RegExp replaces those rules with your pattern's, and that filters chain as intersections.

for a senior

Show that you know why a loose starting locator plus hasText matches every ancestor, and that the cure is choosing a better base set rather than tuning the text pattern.

for a principal

Frame filter as part of a locator vocabulary the whole suite shares. Cheap immutable descriptions let teams name a base set once and derive variants, which keeps test intent readable as the product grows.

## What `filter` actually returns `locator.filter(options)` takes a locator that **already matches a set of elements** and returns a **new locator** matching a subset of that same set. It never descends. If `page.getByRole('listitem')` matches every issue card in a board column, then `.filter({ hasText: 'Blocked' })` still matches *list items* — there are just fewer of them. The element you end up holding is the card, not the status pill inside it that carried the word. Locators in Playwright are immutable **descriptions**, not results. `filter` builds a longer description and hands it back; the locator you called it on is unchanged and still usable, and nothing has touched the DOM yet. The page is queried only when you act on the locator, assert against it, or read from it. That is why the following is safe and idiomatic: ```ts const cards = page.getByTestId('issue-card'); const blocked = cards.filter({ hasText: 'Blocked' }); const mine = cards.filter({ hasText: 'Assigned to me' }); ``` `cards` still matches all of them. ## How `hasText` matches `hasText` searches the element's **entire rendered subtree**, not just its own text node. An issue card whose label deep inside reads `Blocked` matches `filter({ hasText: 'Blocked' })` even though the card element owns no text of its own. With a **string** argument: - matching is **case-insensitive**, so `'blocked'` finds `Blocked`; - matching is a **substring** test, so `'Block'` finds `Blocked by #412`; - whitespace is normalized, so markup line breaks and indentation do not defeat a match. With a **RegExp** argument you get exactly your pattern's semantics — anchors, character classes, and case sensitivity unless you add the `i` flag. That is the escape hatch when the loose string rules match too much. | What you want | What to pass | |---|---| | any card mentioning the word | `filter({ hasText: 'blocked' })` | | a card whose text is exactly that | `filter({ hasText: /^Blocked$/ })` | | a case-sensitive substring | `filter({ hasText: /Blocked/ })` | | cards that never mention it | `filter({ hasNotText: 'Blocked' })` | `hasNotText` is the exact complement: same subtree search, same case-insensitive substring rules, inverted verdict. ## Stacking conditions `filter` is chainable, and each call intersects with the previous one. Reading an issue board, the two calls below compose into "cards that mention Blocked and do not mention Draft": 1. start from the widest honest set — `page.getByTestId('issue-card')`; 2. narrow by what the card says — `.filter({ hasText: 'Blocked' })`; 3. narrow again by what it must not say — `.filter({ hasNotText: 'Draft' })`. Order does not change the result set, because each step is a set intersection. It does change how the locator reads in a failure message, so put the most meaningful condition first. ## The nesting trap Because `hasText` looks at the whole subtree, an **ancestor also matches**. On a board where the column element wraps the cards, `page.locator('div').filter({ hasText: 'Blocked' })` matches the card *and* the column *and* the board wrapper — every ancestor contains that text. The fix is not a better text pattern; it is a tighter starting locator. Filter narrows what you already chose, so a sloppy starting set stays sloppy. ## Where candidates go wrong - Believing `filter` mutates the locator it was called on — it returns a new one. - Expecting `filter({ hasText })` to hand back the matching text node rather than the outer element. - Assuming a string is an exact, case-sensitive comparison; it is a case-insensitive substring test. - Filtering a set that was never narrow enough, then blaming the text match for the extra ancestors. - Reaching for `filter` when the goal is to reach an element *inside* the match — that is chaining, not filtering. Everything above is a description-building step, so it costs nothing at construction time and carries no timeout of its own. The waiting, retrying and timing out happen later, when the locator is finally used.

  • Why can `page.locator('div').filter({ hasText: 'Blocked' })` match far more elements than you expect?
    Because `hasText` searches the whole subtree, every ancestor of the matching text also contains it. The card matches, but so does its column and the board wrapper. The fix is a tighter starting locator, such as `getByTestId('issue-card')`, not a cleverer text pattern.
  • When would you pass a `RegExp` to `hasText` instead of a string?
    When the loose rules match too much. A string is a case-insensitive substring test, so `'Done'` also hits `Not done`. A `RegExp` gives you anchoring (`/^Done$/`), word boundaries, and case sensitivity unless you add the `i` flag.
  • Does calling `.filter()` query the page?
    No. It returns a new locator description and touches nothing. The DOM is queried when the locator is used — an action, an assertion, or a read — and that is where auto-waiting and timeouts apply.

Filtering is a sieve, not a magnifying glass. It removes cards from the pile you already picked up rather than looking deeper inside any one of them.

saying these in an interview costs you the question

  • Says filter mutates the locator it was called on
  • Thinks hasText returns the matching text node, not the element
  • Assumes a string argument is an exact case-sensitive comparison
  • Believes filter searches inside the match for descendants
  • Expects filter to query the page immediately
open as a page

In Playwright, when do you use locator.filter({ has }) instead of chaining locator.locator() to reach an element?

level: middleimportance: must knowfreq 66%

basics

~20 s

Use filter with has when the element you act on is the container and the inner locator is only a test. Chain locator.locator when the target is the descendant itself. Either way the inner locator is relative to the outer match.

open as a page

In Playwright, what is the difference between locator.and() and locator.or() when combining two locators?

level: middleimportance: should knowfreq 42%

basics

~20 s

and() is an intersection: one element must satisfy both locators. or() is a union: an element matching either one qualifies. Use and() to pin down a single element by two properties, and or() to wait for whichever of two outcomes appears.

open as a page

In Playwright, what does locator.all() return, and why can a loop over its result be flaky?

level: seniorimportance: should knowfreq 52%

basics

~20 s

It returns one locator per currently matching element and waits for nothing. The array length is a snapshot, so a list still loading yields too few entries, and each entry is positional, so removing elements mid-loop shifts what the rest point at.

open as a page

A Playwright locator for issue cards matches three more elements than the board renders. How do you narrow it with locator.filter?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Read the set back first to see what the extras are, then exclude them by the property that really distinguishes them: visible true for unrendered nodes, hasNotText or hasNot for content, or a tighter starting locator for ancestors.

open as a page