30

Current Working Solution

Using this html:

<p data-testid="foo">Name: <strong>Bob</strong> <em>(special guest)</em></p>

I can use the React Testing Library getByTestId method to find the textContent:

expect(getByTestId('foo').textContent).toEqual('Name: Bob (special guest)')

Is there a better way?

I would like to simply use this html:

<p>Name: <strong>Bob</strong> <em>(special guest)</em></p>

And use React Testing Library's getByText method like this:

expect(getByText('Name: Bob (special guest)')).toBeTruthy()

But this does not work.

So, the question…

Is there a simpler way to use React Testing Library to find strings of text content with the tags striped out?

Cory House
  • 11,526
  • 8
  • 65
  • 82
Beau Smith
  • 29,103
  • 12
  • 82
  • 88

6 Answers6

29

Update 2

Having used this many times, I've created a helper. Below is an example test using this helper.

Test helper:

// withMarkup.ts
import { MatcherFunction } from '@testing-library/react'

type Query = (f: MatcherFunction) => HTMLElement

const withMarkup = (query: Query) => (text: string): HTMLElement =>
  query((content: string, node: HTMLElement) => {
    const hasText = (node: HTMLElement) => node.textContent === text
    const childrenDontHaveText = Array.from(node.children).every(
      child => !hasText(child as HTMLElement)
    )
    return hasText(node) && childrenDontHaveText
  })

export default withMarkup

Test:

// app.test.tsx
import { render } from '@testing-library/react'
import App from './App'
import withMarkup from '../test/helpers/withMarkup'

it('tests foo and bar', () => {
  const { getByText } = render(<App />)
  const getByTextWithMarkup = withMarkup(getByText)
  getByTextWithMarkup('Name: Bob (special guest)')
})

Update 1

Here is an example where a new matcher getByTextWithMarkup is created. Note that this function extends getByText in a test, thus it must be defined there. (Sure the function could be updated to accept getByText as a parameter.)

import { render } from "@testing-library/react";
import "jest-dom/extend-expect";

test("pass functions to matchers", () => {
  const Hello = () => (
    <div>
      Hello <span>world</span>
    </div>
  );
  const { getByText } = render(<Hello />);

  const getByTextWithMarkup = (text: string) => {
    getByText((content, node) => {
      const hasText = (node: HTMLElement) => node.textContent === text
      const childrenDontHaveText = Array.from(node.children).every(
        child => !hasText(child as HTMLElement)
      )
      return hasText(node) && childrenDontHaveText
    })
  }

  getByTextWithMarkup('Hello world')

Here is a solid answer from the 4th of Five Things You (Probably) Didn't Know About Testing Library from Giorgio Polvara's Blog:


Queries accept functions too

You have probably seen an error like this one:

Unable to find an element with the text: Hello world. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

Usually, it happens because your HTML looks like this:

<div>Hello <span>world</span></div>

The solution is contained inside the error message: "[...] you can provide a function for your text matcher [...]".

What's that all about? It turns out matchers accept strings, regular expressions or functions.

The function gets called for each node you're rendering. It receives two arguments: the node's content and the node itself. All you have to do is to return true or false depending on if the node is the one you want.

An example will clarify it:

import { render } from "@testing-library/react";
import "jest-dom/extend-expect";

test("pass functions to matchers", () => {
  const Hello = () => (
    <div>
      Hello <span>world</span>
    </div>
  );
  const { getByText } = render(<Hello />);

  // These won't match
  // getByText("Hello world");
  // getByText(/Hello world/);

  getByText((content, node) => {
    const hasText = node => node.textContent === "Hello world";
    const nodeHasText = hasText(node);
    const childrenDontHaveText = Array.from(node.children).every(
      child => !hasText(child)
    );

    return nodeHasText && childrenDontHaveText;
  });
});

We're ignoring the content argument because in this case, it will either be "Hello", "world" or an empty string.

What we are checking instead is that the current node has the right textContent. hasText is a little helper function to do that. I declared it to keep things clean.

That's not all though. Our div is not the only node with the text we're looking for. For example, body in this case has the same text. To avoid returning more nodes than needed we are making sure that none of the children has the same text as its parent. In this way we're making sure that the node we're returning is the smallest—in other words the one closes to the bottom of our DOM tree.


Read the rest of Five Things You (Probably) Didn't Know About Testing Library

Beau Smith
  • 29,103
  • 12
  • 82
  • 88
  • I'm not understanding why this is necessary since, as per the [testing-library docs](https://testing-library.com/docs/dom-testing-library/api-queries#bytext), `getByText` looks for the textContent already, and hence `getByText("Hello World")` should work, right (although it seems not to for some reason)? – redOctober13 Aug 16 '19 at 17:21
  • 1
    That's because `getByText` is using the [`getNodeText`](https://github.com/testing-library/dom-testing-library/blob/master/src/get-node-text.js) helper which is looking for the `textContent` property of each *text node*. In your case, the only text nodes that are direct children of `

    ` are `Name: ` and ` `. I'm not sure why RTL decides not to look for text nodes that are children of children in a recursive way. Maybe it's for performance reasons but that's the way it is. Maybe @kentcdodds can provide some more insights on this

    – Giorgio Polvara - Gpx Sep 10 '19 at 14:10
  • 2
    Thinking about it RTL doesn't look for children of children because otherwise this getAllByText(`
    Hello
    `, 'Hello') would return two results. It makes sense
    – Giorgio Polvara - Gpx Sep 10 '19 at 14:13
  • 1
    Nice answer. I also had to catch the exception thrown by `getByText` and re-throw another message with `text`, because it's not included in the error message when using a custom matcher. I think it would be great to have this helper included by default on `@testing-library`. – Paolo Moretti Oct 17 '19 at 10:34
  • @PaoloMoretti - Can you please post the solution you describe as another answer to this question? – Beau Smith Oct 17 '19 at 18:44
  • With simplified internal types: `const withMarkup = (query: Query) => (text: string) => { const hasText = (node: Element) => node.textContent === text; return query((_, node) => { const childrenDontHaveText = Array.from(node.children).every(child => !hasText(child)); return hasText(node) && childrenDontHaveText; }); };` – tanguy_k Apr 08 '20 at 08:55
7

The existing answers are outdated. The new *ByRole query supports this:

getByRole('button', {name: 'Bob (special guest)'})
Cory House
  • 11,526
  • 8
  • 65
  • 82
  • 1
    How would that work in this case, where there is no 'button'? – jarthur Jun 03 '20 at 05:19
  • @jarthur - Use the accessibility DOM to inspect the element you're targeting to determine its role. – Cory House Jun 09 '20 at 23:42
  • 5
    I'm wondering in OP's context, there is no obvious role. Unless `p` has a default role? – jarthur Jun 10 '20 at 04:12
  • 1
    @jarthur -

    has a role of paragraph. However, oddly getByRole ignores paragraphs. So you need to use a different wrapper element that getByRole currently supports like a heading or a region.

    – Cory House Nov 06 '20 at 17:29
4

If you are using testing-library/jest-dom in your project. You can also use toHaveTextContent.

expect(getByTestId('foo')).toHaveTextContent('Name: Bob (special guest)')

if you need a partial match, you can also use regex search patterns

expect(getByTestId('foo')).toHaveTextContent(/Name: Bob/)

Here's a link to the package

3

Update

The solution below works but for some cases, it might return more than one result. This is the correct implementation:

getByText((_, node) => {
  const hasText = node => node.textContent === "Name: Bob (special guest)";
  const nodeHasText = hasText(node);
  const childrenDontHaveText = Array.from(node.children).every(
    child => !hasText(child)
  );

  return nodeHasText && childrenDontHaveText;
});

You can pass a method to getbyText:

getByText((_, node) => node.textContent === 'Name: Bob (special guest)')

You could put the code into a helper function so you don't have to type it all the time:

  const { getByText } = render(<App />)
  const getByTextWithMarkup = (text) =>
    getByText((_, node) => node.textContent === text)
Giorgio Polvara - Gpx
  • 12,268
  • 5
  • 53
  • 55
  • 1
    This solution can work in simple scenarios, however if it produced the error "Found multiple elements with the text: (_, node) => node.textContent === 'Name: Bob (special guest)'", then try the other answer's solution which checks child nodes as well. – Beau Smith Aug 08 '19 at 16:26
  • 2
    Agree, the solution is actually taken from my blog :D – Giorgio Polvara - Gpx Aug 09 '19 at 07:44
  • 1
    Thanks for your insight with this Giorgio. I keep coming back to these answers as I find I need these solutions in new tests. :) – Beau Smith Sep 23 '19 at 13:24
  • 1
    Is there a way to modify this idea to work with cypress-testing-library? – Cory House Dec 13 '19 at 12:31
0

For substring matching, can use exact:

https://testing-library.com/docs/dom-testing-library/api-queries#textmatch

kenberkeley
  • 4,183
  • 25
  • 23
0

To avoid matching multiple elements, for some use cases simply only returning elements that actually have text content themselves, filters out unwanted parents just fine:

expect(
  // - content: text content of current element, without text of its children
  // - element.textContent: content of current element plus its children
  screen.getByText((content, element) => {
    return content !== '' && element.textContent === 'Name: Bob (special guest)';
  })
).toBeInTheDocument();

The above requires some content for the element one is testing, so works for:

<div>
  <p>Name: <strong>Bob</strong> <em>(special guest)</em></p>
</div>

...but not if <p> has no text content of its own:

<div>
  <p><em>Name: </em><strong>Bob</strong><em> (special guest)</em></p>
</div>

So, for a generic solution the other answers are surely better.

Arjan
  • 20,227
  • 10
  • 57
  • 70