如何使用React Testing Library按文本字符串查询包含HTML标记的内容?

101

目前的解决方案

使用以下 HTML 代码:

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

我可以使用React Testing LibrarygetByTestId方法来查找textContent

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

有更好的方法吗?

我想简单地使用这个 html:

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

并使用React Testing LibrarygetByText方法,方法如下:

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

但这并不起作用。

那么问题来了...

是否有更简单的方法使用React测试库查找剥离了标签的文本内容的字符串?


即使p仅具有像FormHelperText MUI一样的id属性,这也可以正常工作。 - Carmine Tambascia
10个回答

52

3
这绝对是最简单的解决方案。在这种情况下,可以使用以下代码:expect(getByText('Name:', { exact: false }).textContent).toEqual('Name: Bob (special guest)'); - Bartlett
1
不需要使用 testId 匹配器的好解决方案! - bntzio

49

更新2

我已经多次使用过这个工具,所以我创建了一个辅助程序。下面是使用这个辅助程序的一个示例测试。

测试辅助程序:

// 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

测试:

// 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)')
})

更新 1

这里是一个示例,创建了一个新的匹配器 getByTextWithMarkup。请注意,此函数在测试中扩展了 getByText,因此必须在那里定义。(当然,该函数可以更新为接受 getByText 作为参数。)

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')
})

这是来自Giorgio Polvara's BlogFive Things You (Probably) Didn't Know About Testing Library第四个问题的确切答案。

查询也接受函数

你可能会看到这样的错误:

无法找到文本为“Hello world”的元素。 这可能是因为文本被多个元素分隔开。 在这种情况下,你可以为你的文本匹配器提供一个函数, 以使你的匹配器更加灵活。

通常,这是因为你的 HTML 看起来像这样:

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

解决方案包含在错误信息中:“[...]您可以为文本匹配器提供一个函数[...]”。
这是什么意思?原来,匹配器接受字符串、正则表达式或函数。
该函数会为您渲染的每个节点调用。它接收两个参数:节点的内容和节点本身。您只需根据节点是否符合您的要求返回true或false。
以下示例将澄清这一点:
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;
  });
});

我们忽略了`content`参数,因为在这种情况下,它要么是"Hello","world"或者是一个空字符串。
我们实际上检查的是当前节点是否具有正确的textContent。`hasText`是一个小助手函数来完成这个任务。我声明它是为了保持代码整洁。
但这还不是全部。我们的`div`并不是唯一一个包含我们要查找文本的节点。例如,在这种情况下,`body`也具有相同的文本。为了避免返回多余的节点,我们确保没有子节点与其父节点具有相同的文本。通过这种方式,我们确保返回的节点是最小的,换句话说就是DOM树底部最接近的节点。
阅读关于Testing Library你(可能)不知道的五件事的其余部分。

1
这是因为 getByText 使用了 getNodeText 辅助函数,该函数查找每个 文本节点textContent 属性。在您的情况下,直接作为 <p> 子元素的唯一文本节点是 Name: 。我不确定 RTL 为什么决定不以递归方式查找子级中的文本节点。也许出于性能原因,但事实就是这样。也许 @kentcdodds 可以提供更多关于此问题的见解。 - Gio Polvara
2
考虑到这一点,RTL 不会查找子元素的子元素,否则 getAllByText(<div><div>Hello</div></div>, 'Hello') 将返回两个结果。这是有道理的。 - Gio Polvara
2
好的答案。我也不得不捕获 getByText 抛出的异常,并重新抛出带有 text 的另一条消息,因为在使用自定义匹配器时,错误消息中不包括它。我认为将这个辅助函数默认包含在 @testing-library 中会很棒。 - Paolo Moretti
使用简化的内部类型:`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
最新更新会产生编译警告。node: HTMLElement 应该改为 node: Element | null。所有其他的 HTMLElement 应该改为 ElementArray.from(node.children):警告 node 可能为空 - DarkTrick
显示剩余3条评论

41
如果你的项目中使用了testing-library/jest-dom,你也可以使用toHaveTextContent
expect(getByTestId('foo')).toHaveTextContent('Name: Bob (special guest)')

如果你需要部分匹配,你也可以使用正则表达式搜索模式。
expect(getByTestId('foo')).toHaveTextContent(/Name: Bob/)

这是一个链接到package的链接。

这只是一个断言,你首先需要其他东西来找到这个元素。OP正在寻找一个查询来找到这个元素。 - T J
这只是一个断言,你首先需要其他东西来找到这个元素。OP正在寻找一个查询来找到这个元素。 - undefined
这个答案可以通过以下断言进行适应,例如 const el = getByText('Name:', { exact: false }); expect(el).toHaveTextContent('Name: Bob (special guest)'); - T J
没错。我之前没有意识到原帖的作者是在寻找一种通过文本内容查询元素的方法。 - forrestDinos

15
现有的答案已经过时了。新的*ByRole查询支持这个功能:
getByRole('button', {name: 'Bob (special guest)'})

1
在这种情况下,没有“按钮”,那该怎么办呢? - jarthur
@jarthur - 使用可访问的DOM来检查您所针对的元素,以确定其角色。 - Cory House
9
在OP的情境中,没有明显的角色。除非“p”有默认角色? - jarthur
1
@jarthur - <p> 具有段落的作用。然而,奇怪的是 getByRole 忽略了段落。因此,您需要使用一个不同的包装元素,目前 getByRole 支持像标题或区域这样的元素。 - Cory House
2
@CoryHouse - 如果没有可访问角色的元素,只有像这样的元素:<div><b>[AL]</b> 阿尔巴尼亚</div> <div><b>[DZ]</b> 阿尔及利亚</div> 我该如何通过文本查询第一个元素? - dariusz
@dariusz - 我建议你改进你的标记语言,使其更具语义化。这样测试会更容易,而且可访问性也可能会更好。 - Cory House

7

更新

下面的解决方案可行,但在某些情况下,它可能会返回多个结果。这是正确的实现:

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;
});

你可以将一个方法传递给 getByText:
getByText((_, node) => node.textContent === 'Name: Bob (special guest)')

你可以将代码放入一个助手函数中,这样就不必一遍又一遍地输入它了:
  const { getByText } = render(<App />)
  const getByTextWithMarkup = (text) =>
    getByText((_, node) => node.textContent === text)

1
这个解决方案可以在简单的场景中工作,但是如果出现错误“Found multiple elements with the text: (_, node) => node.textContent === 'Name: Bob (special guest)'”,那么请尝试另一个答案的解决方案,该解决方案还检查了子节点。 - Beau Smith
2
同意,这个解决方案实际上是从我的博客中获取的 :D - Gio Polvara
1
谢谢您对此的见解,Giorgio。我发现当我需要在新测试中解决这些问题时,我会不断回来查看这些答案。 :) - Beau Smith
1
有没有办法修改这个想法,使其与cypress-testing-library一起工作? - Cory House
编译器警告:[node] Object is possibly null,位于 Array.from(node.children) - DarkTrick

2
现在您可以使用“toHaveTextContent”方法来匹配包含子字符串或标记的文本。
例如:
const { container } = render(
        <Card name="Perro Loko" age="22" />,
    );
expect(container).toHaveTextContent('Name: Perro Loko Age: 22');

1
其他答案最终出现了类型错误或根本无法运行的代码。这个对我有用。
注意:我在这里使用screen.*
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);
}

使用方法:

Jest.getByTextWithMarkup("hello world");
Jest.queryByTextWithMarkup("hello world");

1
为了避免匹配多个元素,在某些情况下只返回实际具有文本内容的元素,可以很好地过滤掉不需要的父级元素。
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();

上述需要一些测试元素的内容,因此适用于以下情况:
<div>
  <p>Name: <strong>Bob</strong> <em>(special guest)</em></p>
</div>

...但如果<p>没有自己的文本内容,则不会产生效果:

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

因此,对于通用解决方案,其他答案肯定更好。


1

您可以使用toHaveTextContent方法查询跨越多行或标签的文本。在React-Testing Library文档这里中可以看到一个示例。

例如

test("use toHaveTextContent to check for content in a component", () => {
  const { container } = render(
    <p>
      A sentence with <span>an important</span> part.
    </p>
  );
  expect(container).toHaveTextContent("A sentence with an important part.");
});

感谢您抽出时间回答这个问题。请注意,这个问题已经在这里发布了几次相同的答案。 - Beau Smith

0

2
这对于<div>hello<span> world</span></div>无效,这正是OP所询问的。 - DarkTrick

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接