How to query by text string which contains html tags using React Testing Library?
Asked Answered
P

8

109

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?

Protrusion answered 4/4, 2019 at 7:13 Comment(1)
This even work if the p has property simply as id as can be for FormHelperText MUIManganous
P
52

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

Protrusion answered 2/7, 2019 at 20:29 Comment(8)
I'm not understanding why this is necessary since, as per the testing-library docs, getByText looks for the textContent already, and hence getByText("Hello World") should work, right (although it seems not to for some reason)?Sponge
That's because getByText is using the getNodeText helper which is looking for the textContent property of each text node. In your case, the only text nodes that are direct children of <p> 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 thisTare
Thinking about it RTL doesn't look for children of children because otherwise this getAllByText(<div><div>Hello</div></div>, 'Hello') would return two results. It makes senseTare
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.Gossoon
@PaoloMoretti - 👍🏼 Can you please post the solution you describe as another answer to this question?Protrusion
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; }); };Guimar
Latest update gives compiler warnings. node: HTMLElement Should be node: Element | null. All other HTMLElements should be Element. Array.from(node.children): Warning *node can be null`.Enter
Can anyone provide an example how to make this helper function work with all BoundFunctions from the Testing Library, like getAllByText, please?Sells
W
64

For substring matching, you can pass { exact: false }:

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

const el = getByText('Name:', { exact: false })
expect(el.textContent).toEqual('Name: Bob (special guest)');
Windhoek answered 26/3, 2020 at 10:41 Comment(2)
This is by far the easiest solution. In this case something like: expect(getByText('Name:', { exact: false }).textContent).toEqual('Name: Bob (special guest)');Aaberg
Great solution that doesn't involve testId matchers!Sobriquet
P
52

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

Protrusion answered 2/7, 2019 at 20:29 Comment(8)
I'm not understanding why this is necessary since, as per the testing-library docs, getByText looks for the textContent already, and hence getByText("Hello World") should work, right (although it seems not to for some reason)?Sponge
That's because getByText is using the getNodeText helper which is looking for the textContent property of each text node. In your case, the only text nodes that are direct children of <p> 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 thisTare
Thinking about it RTL doesn't look for children of children because otherwise this getAllByText(<div><div>Hello</div></div>, 'Hello') would return two results. It makes senseTare
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.Gossoon
@PaoloMoretti - 👍🏼 Can you please post the solution you describe as another answer to this question?Protrusion
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; }); };Guimar
Latest update gives compiler warnings. node: HTMLElement Should be node: Element | null. All other HTMLElements should be Element. Array.from(node.children): Warning *node can be null`.Enter
Can anyone provide an example how to make this helper function work with all BoundFunctions from the Testing Library, like getAllByText, please?Sells
A
44

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

Anastos answered 20/4, 2021 at 18:21 Comment(3)
This is an assertion though, you first need something else to find the element. OP is looking for a query to find the elementCandra
This answer could be adapted with this assertion like const el = getByText('Name:', { exact: false }); expect(el).toHaveTextContent('Name: Bob (special guest)');Candra
That's right. I hadn't realized that the OP was looking for a way to query an element by the text content.Anastos
B
15

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

getByRole('button', {name: 'Bob (special guest)'})
Briefcase answered 7/5, 2020 at 23:25 Comment(6)
How would that work in this case, where there is no 'button'?Unaccomplished
@Unaccomplished - Use the accessibility DOM to inspect the element you're targeting to determine its role.Briefcase
I'm wondering in OP's context, there is no obvious role. Unless p has a default role?Unaccomplished
@Unaccomplished - <p> 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.Briefcase
@CoryHouse - how about if there are no element with accessible role and only elements like this: <div><b>[AL]</b> Albania</div> <div><b>[DZ]</b> Algeria</div> How can I query the first element by its text?Lentigo
@Lentigo - I'd suggest improving your markup so that it's more semantic. Then testing is easier, and likely accessibility will be better too.Briefcase
T
7

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)
Tare answered 4/4, 2019 at 12:29 Comment(5)
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.Protrusion
Agree, the solution is actually taken from my blog :DTare
Thanks for your insight with this Giorgio. I keep coming back to these answers as I find I need these solutions in new tests. :)Protrusion
Is there a way to modify this idea to work with cypress-testing-library?Briefcase
Compiler warning: [node] Object is possibly null at Array.from(node.children.Enter
R
1

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.

Radiophone answered 21/7, 2020 at 21:42 Comment(0)
H
1
getByText('Hello World'); // full string match
getByText('llo Worl', { exact: false }); // substring match
getByText('hello world', { exact: false }); // ignore case-sensitivity

source: https://testing-library.com/docs/react-testing-library/cheatsheet/#queries

Homoiousian answered 30/4, 2022 at 7:55 Comment(1)
This does not work for <div>hello<span> world</span></div>, which is what the OP is asking.Enter
E
1

The other answers ended up in type errors or non-functional code at all. This worked for me.

Note: I'm using screen.* here

import React from 'react';
import { screen } from '@testing-library/react';

/**
 * Preparation: generic function for markup 
 * matching which allows a customized 
 * /query/ function.
 **/
namespace Helper {
    type Query = (f: MatcherFunction) => HTMLElement

    export const byTextWithMarkup = (query: Query, textWithMarkup: string) => {
        return query((_: string, node: Element | null) => {
            const hasText = (node: Element | null) => !!(node?.textContent === textWithMarkup);
            const childrenDontHaveText = node ? Array.from(node.children).every(
                child => !hasText(child as Element)
            ) : false;
        return hasText(node) && childrenDontHaveText
    })}
}


/**
 * Functions you use in your test code.
 **/
export class Jest {
    static getByTextWithMarkup = (textWithMarkup: string) =>  Helper.byTextWithMarkup(screen.getByText, textWithMarkup);
    static queryByTextWith = (textWithMarkup: string) =>  Helper.byTextWithMarkup(screen.queryByText, textWithMarkup);
}

Usage:

Jest.getByTextWithMarkup("hello world");
Jest.queryByTextWithMarkup("hello world");
Enter answered 21/12, 2022 at 8:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.