A React Testing Library test fails with "unable to find an element with the text: Hello Ada", although the page renders that phrase as <span>Hello </span><span>Ada</span>. Why does getByText miss it, and what matching options does the query give you?
answer
- textContent is not what the matcher sees
- own text nodes, not descendants
- split phrase belongs to no single element
- exact, normalizer, regex, function matcher
- guard the predicate against ancestors
basics
~20 sBy default the text matcher compares against each element's own text nodes, not the combined text of its descendants, so a phrase split across child elements matches no single element. Options are a regex, exact: false, a custom normalizer, or a function matcher.
solid answer
~50 sTesting Library's default text matcher looks at an element's own text nodes rather than its full `textContent`, so when the phrase is broken across children no element owns the whole string and the query fails — even though the parent visually reads "Hello Ada". The matcher is configurable: a string is compared exactly after normalisation, `{ exact: false }` makes it a case-insensitive substring match, a regex matches part of a node's text, and a function matcher `(content, element) => boolean` lets you test `element.textContent` yourself — usually with a guard so you match the innermost element and not every ancestor. `normalizer` (and `getDefaultNormalizer`) controls the trim-and-collapse step when whitespace is significant. In practice I would rather assert on the container: query the region by role and check its text content, or fix the component if the split is accidental.
code
javascript · 19 linesimport { screen } from '@testing-library/dom'
document.body.innerHTML = `
<p role="status"><span>Hello </span><span>Ada</span></p>
`
// fails: no element owns the whole phrase in its own text nodes
// screen.getByText('Hello Ada')
// works, but verbose - the guard keeps ancestors from matching too
screen.getByText((content, element) => {
const hasText = (node) => node.textContent === 'Hello Ada'
return hasText(element) &&
Array.from(element.children).every((child) => !hasText(child))
})
// usually better: find by role, assert on the subtree's text
const status = screen.getByRole('status')
console.log(status.textContent) // "Hello Ada"go deeper
Know that a text query looks at one element's own text, so a phrase split across two spans is not found, and that a regex or exact: false handles case and partial matches.
Explain why the default is per-element rather than textContent — full textContent would match every ancestor — and be able to write a function matcher with the child guard.
Show the judgment call: reach for role-plus-toHaveTextContent, or fix an accidental markup split, before adding a bespoke matcher that the next reader has to decode.
Frame it as a convention question — interpolated and translated strings routinely split text nodes, so decide how the team asserts on copy at all rather than letting each test invent its own matcher.
## The default matcher is narrower than textContent The surprising part is that the parent element's `textContent` *is* `"Hello Ada"`. The query still fails because Testing Library's default text matcher does not use `textContent`; it builds the candidate string from the element's **own child text nodes only**, ignoring text that lives inside nested elements. ```javascript // <p><span>Hello </span><span>Ada</span></p> // candidate text for <p> : "" (its text nodes are only whitespace) // candidate text for span #1 : "Hello " // candidate text for span #2 : "Ada" // nothing owns "Hello Ada" ``` That design is deliberate: if the matcher used `textContent`, a query for "Hello Ada" would also match `<body>`, `<main>` and every wrapper in between, and every text query would be hopelessly ambiguous. The cost is this failure mode, which is common in internationalised UIs where interpolated values are wrapped, and in components that style part of a sentence. ## Matching options, from simplest to sharpest **A regex.** `screen.getByText(/hello/i)` matches part of a single node's text. It solves case and partial-match problems, but not the split, because the candidate strings are still per-element. **`exact: false`.** `screen.getByText('hello', { exact: false })` turns the string into a case-insensitive substring match. Same limitation: still per-element. **`normalizer`.** By default the candidate text is trimmed and inner whitespace is collapsed, which is what makes multi-line markup queryable. When whitespace is significant — a code block, a pre-formatted table — override it with `getDefaultNormalizer({ trim: false, collapseWhitespace: false })` or supply your own function. **A function matcher.** This is the only option that genuinely handles the split, because you get the element: ```javascript screen.getByText((content, element) => { const hasText = (node) => node.textContent === 'Hello Ada' const childrenDontHaveText = Array.from(element.children).every( (child) => !hasText(child), ) return hasText(element) && childrenDontHaveText }) ``` The guard matters. Without it the predicate is true for the `<p>`, its parent, `<main>` and `<body>`, and `getByText` throws "found multiple elements" instead. The pattern is: match on `textContent`, then require that no child also matches, so you land on the innermost element that contains the whole phrase. **`ignore`.** Text queries skip `script` and `style` elements by default; the `ignore` option changes that selector. It is rarely what you want, but it explains why text inside a `<style>` block never matches. ## What I would usually do instead The function matcher works, but it is a lot of machinery for asserting one sentence. Two alternatives are usually better. Scope and assert on the container's text: ```javascript const greeting = screen.getByRole('status') expect(greeting).toHaveTextContent('Hello Ada') ``` Here the element is found by role — the durable, top-of-the-ladder query — and the text assertion happens in the matcher, where `toHaveTextContent` looks at the full subtree. That reads better and stays on the accessible tier. Or fix the markup, when the split is accidental. If a translation interpolation wrapped a value in a `<span>` for no reason, removing the wrapper makes the phrase queryable for the test *and* keeps the sentence intact as a single text node for tools that care about text. ## The takeaway an interviewer wants The candidate who says "use a function matcher" has read the docs. The candidate who also says "but I would first ask whether I should be finding this element by role and asserting on its text content instead" has understood the query ladder: `getByText` is for locating content, and when locating by text gets hard, that is usually a hint that some more meaningful handle — a role, a landmark, a status region — is the right anchor.
- Why does the default matcher ignore text inside nested elements at all?Because using full textContent would make almost every text query ambiguous: the phrase you want also lives in every ancestor up to <body>, so getByText would throw on multiple matches constantly. Restricting the candidate to an element's own text nodes gives each phrase a single natural owner, at the cost of this split-text failure.
- What does the normalizer option control, and when would you override it?It runs over the candidate text before comparison; by default it trims and collapses runs of whitespace, which is what lets you query text written across several lines of markup. Override it — via getDefaultNormalizer with trim or collapseWhitespace turned off, or your own function — when whitespace is meaningful, such as pre-formatted content or a diff view.
- Your function matcher now reports "found multiple elements". What went wrong?The predicate is true for the target and for every ancestor whose textContent also contains the phrase. Add the standard guard: require that the element's textContent matches and that none of its direct children match, so only the innermost element containing the whole phrase qualifies.
saying these in an interview costs you the question
- Says getByText compares against textContent by default
- Writes a function matcher with no ancestor guard
- Blames the runner or the DOM implementation for the miss
- Reaches for a test id the moment a text query is fiddly
- Thinks exact: false will find text split across elements