In Playwright, when do you use locator.filter({ has }) instead of chaining locator.locator() to reach an element?
answer
- Which element do you end up holding?
- has is a test, chaining is a move
- hasNot inverts the same test
- Inner locator resolved from the outer match
- Filter to pick, then chain to act
basics
~20 sUse 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.
solid answer
~40 sBoth narrow using something inside, but they leave you holding different elements. `cards.filter({ has: page.getByRole('button', { name: 'Assign' }) })` still matches **cards** — the inner locator is a yes/no test applied to each candidate, and `hasNot` inverts it. `cards.locator('button')` matches the **buttons** inside those cards, because chaining searches descendants and moves the match inward. So filter by `has` when you will click or assert on the container, and chain when you want the control itself. Both resolve the inner locator **relative to the outer match**, not from the document root, which is why `filter({ has: page.getByRole('list').getByRole('heading') })` never matches — the path walks through an ancestor that cannot exist under a single card. Both also require the two locators to belong to the same frame.
code
typescript · 14 linesconst cards = page.getByTestId('issue-card');
// filter({ has }) -> still a card
const loginCard = cards.filter({
has: page.getByRole('heading', { name: 'Login bug' }),
});
await expect(loginCard).toHaveCount(1);
// chain -> now the button inside that card
await loginCard.getByRole('button', { name: 'Assign' }).click();
// scope a reusable locator to a container
const saveButton = page.getByRole('button', { name: 'Save' });
await page.getByTestId('comment-composer').locator(saveButton).click();go deeper
Learn the one-line rule: has keeps you on the outer element, chaining moves you to the inner one. Pick by asking what you will click or assert on.
Explain that the inner locator is a subtree test resolved from the outer match, that hasNot inverts it, and that a document-rooted inner locator silently matches nothing.
Show the filter-then-chain habit on real markup and explain why collapsing both steps into one selector produces the brittle locators a suite later has to unpick.
Own the convention: a shared vocabulary of base locators, filtered and scoped consistently, is what keeps failure messages diagnosable across hundreds of tests written by different people.
## Two ways to say "the card with the button" On an issue board, both of these narrow a set of cards using a button inside them, and they are not interchangeable: ```ts const cards = page.getByTestId('issue-card'); // A: still a card — the one that contains an "Assign" button. const cardWithAssign = cards.filter({ has: page.getByRole('button', { name: 'Assign' }) }); // B: now a button — the "Assign" button inside those cards. const assignButton = cards.locator('button', { hasText: 'Assign' }); ``` The difference that matters is **which element you end up holding**. `filter({ has })` keeps the match on the outer element and uses the inner locator only as a test. Chaining with `locator.locator()` moves the match inward to the descendant. ## `filter({ has })` keeps the outer element `has` answers a yes/no question about each candidate: *does this element contain something matching the inner locator?* Candidates that answer no are dropped; the rest are returned unchanged. This is what you want when the thing you will act on or assert about is the container: - click the archive control **of the card that has an "Assign" button**; - assert that exactly one card **contains a blocked badge**; - scope a later search to **the column that holds the issue you seeded**. `hasNot` is the mirror image and drops the candidates that *do* contain a match. ## Chaining descends `locator.locator(selectorOrLocator)` searches **inside** each element of the current match and returns the descendants it finds. It is the tool when the target is the inner element itself. It also accepts a `Locator` rather than a string, which is how a reusable locator gets scoped to a container: ```ts const saveComment = page.getByRole('button', { name: 'Save' }); const composer = page.getByTestId('comment-composer'); await composer.locator(saveComment).click(); ``` That reads as "the Save button, but only the one inside the comment composer" — the same idea as scoping, expressed with a locator you already had. ## The relativity rule that bites everyone The inner locator passed to `has`, `hasNot`, or `locator()` is **resolved starting from the outer match, not from the document root**. It must therefore be relative. This is the single most common failure: ```ts // WRONG: the inner locator walks from the document root through a list, // which cannot be found underneath a single card. cards.filter({ has: page.getByRole('list').getByRole('heading', { name: 'Login bug' }) }); // RIGHT: the inner locator describes something under the card. cards.filter({ has: page.getByRole('heading', { name: 'Login bug' }) }); ``` Building the inner locator off `page` is fine and normal — only its **path** must make sense relative to the outer element. Both locators must also belong to the same frame; a cross-frame inner locator is rejected. ## Which one do you want? | Question | Use | You end up with | |---|---|---| | Which card contains an Assign button? | `cards.filter({ has: button })` | the card | | Which card has no blocked badge? | `cards.filter({ hasNot: badge })` | the card | | Click the Assign button of that card | `card.getByRole('button', …)` | the button | | The Save button, but only inside the composer | `composer.locator(saveButton)` | the button | ## A pattern worth internalising Most real narrowing is one of each, in order: 1. take the widest honest set — `page.getByTestId('issue-card')`; 2. **filter** it down to the one you mean — `.filter({ hasText: 'Login bug' })` or `.filter({ has: … })`; 3. **chain** into it for the control you will actually drive — `.getByRole('button', { name: 'Assign' })`. Written that way, the locator reads like a sentence about the product, and a failure message names the step that broke rather than a CSS path. Collapsing steps two and three into one selector is what produces the brittle locators teams later have to unpick. ## Failure modes - Using `filter({ has })` and then wondering why the click landed on the container. - Chaining when the assertion is about the container, so the assertion describes the wrong element. - Passing a document-rooted inner locator into `has` and getting a permanent zero-match. - Forgetting that `has` and chaining both require the inner locator to live in the same frame.
- Why does `filter({ has: page.getByRole('list').getByRole('heading') })` never match anything?The inner locator is queried starting at each outer match, not at the document root, so it must be relative. That path looks for a heading under a list under the card, and no list exists there. Drop the ancestor step and pass the heading locator alone.
- What does `locator.locator()` gain from accepting a `Locator` argument rather than a selector string?It lets a named, reusable locator be scoped to a container without restating it. `composer.locator(saveButton)` keeps one definition of the Save button and applies it inside the comment composer, so a change to that button's definition updates every scoped use.
- How would you express "the card that has no blocked badge"?`cards.filter({ hasNot: page.getByTestId('blocked-badge') })`. `hasNot` runs the same relative subtree test as `has` and keeps the candidates that do not contain a match, still returning cards rather than badges.
saying these in an interview costs you the question
- Thinks filter with has returns the inner element
- Uses chaining when the assertion is about the container
- Builds a document-rooted inner locator for has
- Believes has accepts only a CSS string, not a locator
- Assumes has and chaining work across frames