skip to content

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%

answer

  1. Read the set back before changing anything
  2. Compare raw text with rendered text
  3. Hidden templates are the usual culprit
  4. Exclude by the real distinguishing property
  5. Lock the result with a count assertion

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.

solid answer

~40 s

Diagnose before you change anything. `await cards.count()` plus `await cards.allTextContents()` shows what actually matched, and comparing that with `allInnerTexts()` — which reports rendered text only — proves quickly whether the extras are hidden. Hidden card templates and off-screen columns are the usual causes, and `filter({ visible: true })`, available since Playwright 1.51, removes them; `visible: false` inverts it, and 1.63 adds a `locator.visible()` shortcut for the common case. If the extras are rendered but wrong, exclude them by content with `filter({ hasNotText: 'Archived' })` or `filter({ hasNot: someLocator })`, both of which keep the match on the card. If the extras are ancestors or belong to a different column, the honest fix is a tighter or scoped starting locator rather than a filter. Then pin the result with a retrying count assertion.

code

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

// 1. Diagnose: what did we actually match?
console.log(await cards.count());
console.log(await cards.allTextContents()); // raw text, hidden nodes included
console.log(await cards.allInnerTexts());  // rendered text only

// 2. Extras were hidden templates -> drop them by visibility.
const visibleCards = cards.filter({ visible: true });

// 3. Extras that render but are archived -> drop them by content.
const openCards = visibleCards.filter({ hasNotText: 'Archived' });

// 4. Lock it in so a regression fails loudly.
await expect(openCards).toHaveCount(6);

go deeper

for a junior

Learn to look before you edit. Print the count and read the matched text back, because knowing what the extra elements are decides which filter option is the right one.

for a middle

Explain what each exclusion actually tests: visible true checks rendering, hasNotText checks subtree text case-insensitively, and hasNot checks for a descendant matching a relative locator.

for a senior

Show the whole loop on a real board: diagnose, choose the narrowest honest filter, and lock the outcome with a retrying count assertion so a regression fails loudly instead of drifting.

for a principal

Push back on the root cause. Repeated visibility filtering across a suite usually means the application leaks templates or reuses test ids, and fixing that upstream is cheaper than maintaining filters in every test.

## Diagnose before you filter A count that is off by three is information, not noise. Before changing the locator, find out **what the extra matches are**. The cheapest way is to read the whole set back: ```ts const cards = page.getByTestId('issue-card'); console.log(await cards.count()); console.log(await cards.allTextContents()); ``` `allTextContents()` reads raw `textContent` for every match, including elements that are not rendered — which is exactly what you want here, because invisible extras are the usual culprit. Contrast it with `allInnerTexts()`, which reports rendered text only: if a card shows up in the first list and as an empty string in the second, you have just proved it is hidden. That two-line diff is faster than any amount of staring at the DOM. The extras almost always turn out to be one of: - a **hidden card template** the framework keeps in the DOM for cloning; - cards from a **collapsed or off-screen column** that share the same test id; - **stale nodes** from the previous board still in a leaving transition; - an **ancestor** that also matched, because the starting locator was too loose. ## Narrowing by visibility For the first three, `locator.filter({ visible: true })` — available since Playwright 1.51 — keeps only the elements that are actually visible: ```ts const visibleCards = page.getByTestId('issue-card').filter({ visible: true }); await expect(visibleCards).toHaveCount(6); ``` Passing `visible: false` inverts it and matches only the invisible ones, which is occasionally useful for asserting that a template stayed hidden. Playwright 1.63 also adds a `locator.visible()` shortcut for the common `visible: true` case; the filter option is the general form and reads naturally alongside other filter conditions. ## Narrowing by content For extras that are visible but simply not the cards you meant, exclude them by what they contain: - `filter({ hasNotText: 'Archived' })` drops cards mentioning that word anywhere in their subtree, case-insensitively; - `filter({ hasNot: page.getByTestId('placeholder-badge') })` drops cards containing a matching descendant, with the inner locator resolved relative to each card. Both keep the match on the card, so whatever you do next still targets the container. ## Which exclusion fits | The extras are | Reach for | |---|---| | present but not rendered | `filter({ visible: true })` | | rendered, identified by wording | `filter({ hasNotText: '…' })` | | rendered, identified by a child | `filter({ hasNot: someLocator })` | | ancestors of a real card | a tighter starting locator | | in another container entirely | scope with `column.locator(cards)` | That last row matters: if the extras live in a different column, the honest fix is scoping the search to the container you mean, not filtering the whole page's worth of results afterwards. ## What not to reach for Three tempting shortcuts each hide the problem instead of fixing it: 1. **Indexing past the extras.** Taking a fixed position works until the hidden nodes render in a different order, and it encodes a DOM detail nothing guarantees. 2. **Forcing the action.** Skipping actionability checks does not make the locator correct; it makes a wrong-element click succeed silently. 3. **Waiting longer.** A hidden template does not disappear with time. If the count is wrong because of extra nodes rather than timing, no timeout value fixes it. ## Keeping the fix honest After narrowing, lock the result in with a retrying count assertion so a regression that reintroduces the extras fails loudly rather than being absorbed. And prefer the narrowest fix that names the real distinction: `visible: true` when visibility genuinely is the difference, a content exclusion when it is not. A filter chosen because it happens to produce the right number today is a locator that will mislead the next person who reads it.

  • How does comparing `allTextContents()` with `allInnerTexts()` tell you the extras are hidden?
    The first reads raw `textContent`, which is present even for unrendered nodes; the second reads `innerText`, which reflects what is rendered. An entry that carries text in the first list and an empty string in the second is in the DOM but not visible.
  • When is a filter the wrong fix for an over-matching locator?
    When the extras are ancestors that also match, or cards in a different column. Filtering the whole page's results afterwards hides a locator that was never right. Tighten the base locator, or scope it with `column.locator(cards)` so the search never leaves the container.
  • Why pin the narrowed locator with a retrying count assertion?
    Because the filter encodes an assumption about the page. A count assertion turns a regression that reintroduces the extras into a loud failure at a named step, instead of letting a later action quietly pick the wrong card.

saying these in an interview costs you the question

  • Changes the locator before looking at what matched
  • Indexes past the extras with a fixed position
  • Forces the action to bypass the extra elements
  • Raises the timeout to fix a count problem
  • Picks whichever filter yields the right number today
  • Assumes hidden nodes eventually leave the DOM