react-testing-library的简介

684 阅读7分钟

两周前,我写了一个新的图书馆!我已经想了很久了。但两周前我开始相当认真地对待它。

继续阅读,了解我所说的 "破坏性做法 "是什么意思。

简单而完整的React DOM测试工具,鼓励良好的测试实践。

问题是

你想为你的React组件编写可维护的测试。作为这个目标的一部分,你希望你的测试避免包括你的组件的实现细节,而是专注于使你的测试给你的信心,他们的目的是什么。作为这个目标的一部分,你希望你的测试库从长远来看是可维护的,所以你的组件的重构(对实现的改变,但不是功能)不会破坏你的测试,也不会拖累你和你的团队。

这个解决方案

react-testing-library 是一个非常轻量级的解决方案,用于测试React组件。它在react-domreact-dom/test-utils 的基础上提供了轻量级的实用功能,以鼓励更好的测试实践。它的主要指导原则是。

因此,与其处理渲染的反应组件实例,你的测试将与实际的DOM节点一起工作。这个库所提供的工具有助于以用户的方式查询DOM。通过标签文本找到表单元素(就像用户那样),通过文本找到链接和按钮(就像用户那样)。它还公开了一种推荐的方法,即通过data-testid ,作为元素的 "逃生舱",在文本内容和标签没有意义或不实用的情况下,可以找到元素。

这个库鼓励你的应用程序更容易访问,并允许你让你的测试更接近于以用户的方式使用你的组件,这使得你的测试给你更多的信心,当一个真正的用户使用它时,你的应用程序将会工作。

这个库是的替代品。虽然你可以使用enzyme本身来遵循这些准则,但由于enzyme提供的所有额外的实用程序(方便测试实现细节的实用程序),执行起来比较困难。请在FAQ中阅读更多这方面的内容。

另外,虽然React测试库是为react-dom设计的,但你可以使用React Native测试库,它的API非常相似。

这个库不是什么

  1. 一个测试运行器或框架
  2. 特定的测试框架(尽管我们推荐Jest作为我们的首选,但该库可用于任何框架,甚至可用于codesandbox!)。

实例

基本例子

// hidden-message.js
import * as React from 'react'

// NOTE: React Testing Library works with React Hooks _and_ classes just as well
// and your tests will be the same however you write your components.
function HiddenMessage({children}) {
  const [showMessage, setShowMessage] = React.useState(false)
  return (
    <div>
      <label htmlFor="toggle">Show Message</label>
      <input
        id="toggle"
        type="checkbox"
        onChange={e => setShowMessage(e.target.checked)}
        checked={showMessage}
      />
      {showMessage ? children : null}
    </div>
  )
}

export default HiddenMessage

// __tests__/hidden-message.js
// These imports are something you'd normally configure Jest to import for you automatically.
// Learn more in the setup docs: https://testing-library.com/docs/react-testing-library/setup#skipping-auto-cleanup
import '@testing-library/jest-dom/extend-expect'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import HiddenMessage from '../hidden-message'

test('shows the children when the checkbox is checked', () => {
  const testMessage = 'Test Message'
  render(<HiddenMessage>{testMessage}</HiddenMessage>)

  // query* functions will return the element or null if it cannot be found
  // get* functions will return the element or throw an error if it cannot be found
  expect(screen.queryByText(testMessage)).toBeNull()

  // the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
  userEvent.click(screen.getByLabelText(/show/i))

  // .toBeInTheDocument() is an assertion that comes from jest-dom
  // otherwise you could use .toBeDefined()
  expect(screen.getByText(testMessage)).toBeInTheDocument()
})

实例

// login.js
import * as React from 'react'

function Login() {
  const [state, setState] = React.useReducer((s, a) => ({...s, ...a}), {
    resolved: false,
    loading: false,
    error: null,
  })

  function handleSubmit(event) {
    event.preventDefault()
    const {usernameInput, passwordInput} = event.target.elements

    setState({loading: true, resolved: false, error: null})

    window
      .fetch('/api/login', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
          username: usernameInput.value,
          password: passwordInput.value,
        }),
      })
      .then(r => r.json())
      .then(
        user => {
          setState({loading: false, resolved: true, error: null})
          window.localStorage.setItem('token', user.token)
        },
        error => {
          setState({loading: false, resolved: false, error: error.message})
        },
      )
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="usernameInput">Username</label>
          <input id="usernameInput" />
        </div>
        <div>
          <label htmlFor="passwordInput">Password</label>
          <input id="passwordInput" type="password" />
        </div>
        <button type="submit">Submit{state.loading ? '...' : null}</button>
      </form>
      {state.error ? <div role="alert">{state.error.message}</div> : null}
      {state.resolved ? (
        <div role="alert">Congrats! You're signed in!</div>
      ) : null}
    </div>
  )
}

export default Login

// __tests__/login.js
// again, these first two imports are something you'd normally handle in
// your testing framework configuration rather than importing them in every file.
import '@testing-library/jest-dom/extend-expect'

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

test('allows the user to login successfully', async () => {
  // mock out window.fetch for the test
  const fakeUserResponse = {token: 'fake_user_token'}
  jest.spyOn(window, 'fetch').mockImplementationOnce(() => {
    return Promise.resolve({
      json: () => Promise.resolve(fakeUserResponse),
    })
  })

  render(<Login />)

  // fill out the form
  userEvent.type(screen.getByLabelText(/username/i), 'chuck')
  userEvent.type(screen.getByLabelText(/password/i), 'norris')

  userEvent.click(screen.getByText(/submit/i))

  // just like a manual tester, we'll instruct our test to wait for the alert
  // to show up before continuing with our assertions.
  const alert = await screen.findByRole('alert')

  // .toHaveTextContent() comes from jest-dom's assertions
  // otherwise you could use expect(alert.textContent).toMatch(/congrats/i)
  // but jest-dom will give you better error messages which is why it's recommended
  expect(alert).toHaveTextContent(/congrats/i)
  expect(window.localStorage.getItem('token')).toEqual(fakeUserResponse.token)
})

这个例子中最重要的收获是。

测试是以类似于用户如何使用你的应用程序的方式来编写的。

让我们进一步探讨这个问题...

假设我们有一个GreetingFetcher 组件,为用户获取问候语。它可能会呈现一些像这样的HTML。

<div>
  <label for="name-input">Name</label>
  <input id="name-input" />
  <button>Load Greeting</button>
  <div data-testid="greeting-text" />
</div>

所以功能是。设置名称,点击 "加载问候语 "按钮,服务器就会请求加载带有该名称的问候语文本。

在你的测试中,你需要找到<input /> ,这样你就可以把它的value 到一些东西。传统的观点认为你可以在一个CSS选择器中使用id 属性:#name-input 。但用户会这样做来找到这个输入吗?他们会看着屏幕,找到标签为 "姓名 "的输入,然后填入。所以这就是我们的测试对getByLabelText 。它根据标签来获得表单控件。

通常在使用酶的测试中,为了找到 "Load Greeting "按钮,你可能会使用一个CSS选择器,甚至通过组件displayName 或组件构造器来寻找。但是当用户想加载问候语时,他们并不关心这些实现细节,相反,他们会找到并点击写着 "加载问候语 "的按钮。而这正是我们的测试用getByText 帮助器所做的事情!

此外,wait ,与用户所做的完全相似。他们等待问候文本的出现,无论需要多长时间。在我们的测试中,我们正在模拟,所以它基本上是即时发生的,但我们的测试实际上并不关心它需要多长时间。我们不需要在测试中使用setTimeout 或任何东西。 我们只是说。"嘿,等到greeting-text 节点出现。"(注意,在这种情况下,它使用了一个data-testid 属性,这是一个逃生舱门,用于通过任何其他机制找到一个元素都没有意义的情况。一个data-testid 肯定比其他方法更好。

高级别的概述API

最初,该库只提供了queryByTestId ,作为我的博文"让你的UI测试对变化有弹性"中的建议。但由于Bergé Greg对该博文的反馈,以及Jamie White精彩(简短!)的演讲的启发,我又增加了几个,现在我对这个解决方案更加满意了。

你可以在官方文档中阅读更多关于这个库和它的API。 下面是这个库给你带来的高层次概述:

  • Simulate:从Simulate 实用程序中重新导出,从react-dom/test-utils Simulate 对象中重新导出。
  • wait: 允许你在测试中等待一段非确定的时间。 通常你应该模拟出API请求动画,但即使你处理的是立即解决的承诺,你也需要你的测试来等待事件循环的下一次勾选,而wait 在这方面真的很好。(要感谢ŁukaszGozda Gandecki,他将其作为(现已废弃的)flushPromises API的替代品)。
  • render: 这是该库的核心部分。它相当简单。它用document.createElement 来创建一个div,然后用ReactDOM.render 来渲染到那个div

render 函数返回以下对象和实用程序:

  • container: 你的组件被渲染到的div
  • unmount: 在ReactDOM.unmountComponentAtNode上的一个简单的包装器,用来卸载你的组件(例如,为了方便测试componentWillUnmount )。
  • getByLabelText: 获取一个与标签相关的表单控件
  • getByPlaceholderText: 占位符不是标签的适当替代品,但如果这对你的用例更有意义,它是可用的。
  • getByText: 通过文本内容获取任何元素。
  • getByAltText: 通过它的alt 属性值来获取一个元素(如<img )。
  • getByTestId: 通过它的data-testid 属性来获取一个元素。

如果找不到任何元素,每个get* 工具都会抛出一个有用的错误信息。还有一个相关的query* API,它将返回null,而不是抛出一个错误,这对断言一个元素在DOM中很有用。

另外,对于这些get* ,要找到一个匹配的元素,你可以传递:

  • 一个不区分大小写的子串:lo world 匹配Hello World
  • 一个反义词:/^Hello World$/ 匹配Hello World
  • 一个接受文本和元素的函数:(text, el) => el.tagName === 'SPAN' && text.startsWith('Hello') 将会匹配一个内容以字母开头的跨段。Hello

自定义Jest匹配器

感谢Anto Aravinth Belgin Rayen,我们也有一些方便的自定义Jest匹配器:

注意:现在这些已经被提取到jest-dom,由Ernesto García维护。

结论

这个库的一大特点是,它没有测试实现细节的工具。它专注于提供鼓励良好测试和软件实践的实用程序。我希望,通过使用react-testing-library你的React测试库会更容易理解和维护。