In a Playwright test, when an issue title sits inside a card, which elements does page.getByText('Fix login redirect') match?
answer
- Ancestors are not part of the result
- Playwright keeps the smallest matching element
- A matching child removes the parent
- Split text changes which element wins
- Repetition is a different problem entirely
basics
~20 sOnly the innermost element whose own text matches. Playwright drops any element that has a child element also matching, so the card and its wrapper are excluded and the span holding the title is returned.
solid answer
~40 s`page.getByText` walks every element and keeps the *smallest* one that matches: if a child element also matches the same text, the parent is dropped. So for a card wrapping a link wrapping a span, only the span is returned — not the card, the link, or `body`, even though all of them contain the string. The rule is per-element, not per-depth: if the title is split across children, as in `Fix <b>login</b> redirect`, no single child carries the whole string, so the parent element is the smallest match and it is the one returned. Two consequences: a text locator can be handed straight to a click without extra scoping, and when it does resolve to several elements the cause is genuinely repeated copy, not nesting.
code
typescript · 16 linesimport { expect, test } from '@playwright/test';
test('a text locator resolves to the innermost element', async ({ page }) => {
await page.setContent(`
<div class="card">
<a href="/issues/42"><span>Fix login redirect</span></a>
</div>
`);
// The card, the link and body all contain the text; only the span is returned.
await expect(page.getByText('Fix login redirect')).toHaveCount(1);
// No child holds the whole string here, so the div itself is the match.
await page.setContent('<div>Fix <b>login</b> redirect</div>');
await expect(page.getByText('Fix login redirect')).toHaveCount(1);
});go deeper
Remember the headline: the innermost element holding the text is what you get, not the card around it. That is why you can usually click a text locator directly without any extra scoping.
Explain the mechanism as a per-element test: an element matches, and is then dropped if a child element matches too. Use the split-text case to show the rule is about children, not depth.
Separate the two failure modes in review. Nesting is already handled by the rule, so a locator resolving to several elements means repeated copy, and the fix is a narrower search root rather than a stricter text argument.
Set the expectation for the suite: text locators are for identifying content, container scoping is for identifying instances. Codifying that split keeps a large suite from drifting into ad hoc positional selectors.
## The rule `page.getByText(...)` considers **every** element in the search root and asks two questions of each: does this element's text match, and does any of its child elements also match? An element is returned only when the answer is *yes* then *no* — that is, it matches and nothing inside it does. Playwright therefore returns the **smallest matching element**, and never an ancestor of one. Take a typical issue-board card: ```html <div class="card"> <a href="/issues/42"><span>Fix login redirect</span></a> </div> ``` Every one of `body`, `div.card`, `a` and `span` contains the string `Fix login redirect`. Only the `span` is returned: the other three each have a descendant that matches, so they are dropped. ## Why the smallest element is the useful one If ancestors were returned too, almost every text query would resolve to a chain of nested elements and would be unusable for an action. The smallest-element rule makes the common case work without scoping: - The returned element is the one that visually *is* the text, so its bounding box is the text's box — a click lands where a user would click. - Assertions on the text read naturally, because the element holds nothing else. - A count of matches means what you expect: how many places the copy appears, not how deep the DOM is. ## When the parent is the match The rule is about *matching children*, not about depth, so a parent is returned whenever no child carries the whole string on its own: ```html <div>Fix <b>login</b> redirect</div> ``` Here the `div`'s normalized text is `Fix login redirect` and matches; the `b` element's text is only `login` and does not. The `div` is therefore the smallest match and is returned. This is why a text locator survives a designer wrapping one word in `<b>` or `<em>` — an event that breaks a brittle CSS selector. | Markup | `getByText('Fix login redirect')` returns | |---|---| | `<span>Fix login redirect</span>` inside a card | the `span` only | | `<div>Fix <b>login</b> redirect</div>` | the `div`, since no child holds it all | | Two cards with the same title | both innermost elements, an ambiguous locator | | `<input type="button" value="Fix login redirect">` | the input, matched by its value | ## Two details worth knowing 1. **Buttons and submit inputs are matched by their `value`.** `<input type="button" value="Add comment">` has no text content, yet `getByText('Add comment')` finds it, because Playwright reads the `value` attribute for these input types. 2. **`script`, `noscript` and `style` content is skipped**, as is anything in the document head. A JSON blob inside a `<script>` tag that happens to contain the issue title will not produce a phantom match. ## What this does not solve The smallest-element rule removes ancestors from the result; it does nothing about **repetition**. On a board where three cards share the title `Fix login redirect`, the locator resolves to three innermost elements and any action on it is ambiguous. That is a scoping problem: narrow the search root to one card first, then run the text query inside it, so the query only ever sees one candidate. The rule also does nothing about text you *think* is one string but the DOM splits with punctuation or a non-breaking space between the parts. If a locator that looks right resolves to nothing, print the rendered text before blaming the matcher: - copy the text out of the DOM rather than the design mockup; - watch for a non-breaking space where the mockup shows a normal one; - watch for an ellipsis character inserted by a truncation utility; - remember that whitespace is normalized, so indentation is never the cause. ## Interview framing A strong answer states the rule in one sentence — the innermost matching element wins, ancestors are dropped — then gives the `Fix <b>login</b> redirect` case that shows the rule is about children rather than depth, and closes by separating nesting (solved by the rule) from repetition (solved by scoping). Candidates who cannot separate those two usually reach for the wrong fix when a text locator misbehaves.
- How can a text locator match an element that has no text content at all?Input elements of type `button` and `submit` are matched by their `value` attribute rather than their text content, because they have none. So `getByText('Add comment')` finds `<input type="button" value="Add comment">`. It is the one case where the text query reads an attribute instead of the rendered text.
- The title is unique on the page but the locator still resolves to two elements. What would you check?Look for a second, hidden copy of the same copy — a screen-reader-only span, a mobile variant kept in the DOM, or a tooltip. The text query does not filter by visibility, so an off-screen duplicate counts. Scope the query to the visible container, or narrow with a container locator first.
saying these in an interview costs you the question
- Says the outermost container matching text is returned
- Thinks every ancestor of the text also matches
- Believes the deepest element always wins regardless of text
- Assumes text inside a script tag can match
- Confuses repeated copy with a nesting problem