skip to content

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