React Testing Library使用总结

11,666 阅读14分钟

React Testing Library

React Testing Library是基于DOM Testing Library构建的,它提供了一些用于处理React components的api。(如果使用Create React App创建的项目,那么它已经支持使用React Testing Library编写测试代码)

问题

如果你希望为你的WEB UI 编写可维护的测试。为了实现这个目标,你希望测试可以避开组件的具体实现细节,而是更关注于其是否能保证实现你期望的功能。另一方面,测试库应该是长期可维护的状态,在改变应用的实现方式不改变功能的情况下(也就是代码重构),不需要重新编写测试,拖慢项目进度。

解决

React Testing Library是测试React components的非常轻量级的解决方案。它提供的主要功能是类似于用户在页面上查找元素的方式查找DOM节点。通过这种测试方式,可以让你确保Web UI是否能正常工作。React Testing Library的主要指导原则是:

你的测试越像你的软件使用的方式,测试就越能给你带来信心

你可以通过Label查找表单元素,通过Text查找链接和按钮,以及其它类似的查找方式。同时它还提供data-testid用于查找内容或标签没有意义或不实际的元素(我的理解是类似按钮是一个图标的情况,无法直接描述)

这个库是Enzyme的替代品。你可以使用Enzyme遵循上面的规则进行测试,但是因为Enzyme提供了很多额外的对于应用实现细节测试的功能,所以强行使用它会增加测试编写的难度。

不具备的部分

  1. 测试运行程序或框架
  2. 特定于某个测试框架

React Testing Library 常见测试场景

Rendering a component

下面是待测试组件:

import React from 'react';
 
const title = 'Hello React';
 
function App() {
  return <div>{title}</div>;
}
 
export default App;

在测试中可以通过render渲染一个组件,然后在后面的测试中便可以访问该组件

import React from 'react';
import { render } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
  });
});

可以通过screen.debug()查看渲染出来的HTML DOM树是什么样的,在写测试代码前,先通过debug查看当前页面中可见的元素,再开始查询元素,这会有助于编写测试代码.

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    screen.debug();
  });
});
<body>
  <div>
    <div>
      Hello React
    </div>
  </div>
</body>

下面是使用了React的一些特性(useState,event handler,props,component)以后,生成的HTML DOM树.可以看出React Testing Library 不关心真实的组件的编写方式,最后渲染出来的还是普通的HTML DOM树.所以我们在测试的时候,也只需要针对渲染出来的HTML DOM树进行测试即可.

import React from 'react';
 
function App() {
  const [search, setSearch] = React.useState('');
 
  function handleChange(event) {
    setSearch(event.target.value);
  }
 
  return (
    <div>
      <Search value={search} onChange={handleChange}>
        Search:
      </Search>
 
      <p>Searches for {search ? search : '...'}</p>
    </div>
  );
}
 
function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input
        id="search"
        type="text"
        value={value}
        onChange={onChange}
      />
    </div>
  );
}
 
export default App;
<body>
  <div>
    <div>
      <div>
        <label
          for="search"
        >
          Search:
        </label>
        <input
          id="search"
          type="text"
          value=""
        />
      </div>
      <p>
        Searches for
        ...
      </p>
    </div>
  </div>
</body>

React Testing库用于像用户一样与React组件进行交互。用户看到的只是从React组件渲染的HTML,因此这就是为什么将此HTML结构视为输出而不是两个单独的React组件的原因。

Selecting elements

在渲染完React组件以后,React Testing Library为你提供了多种不同的搜索方法用来获取元素.获取到的元素便可以用来进行后面的断言或者用户交互操作.下面来看看如何使用它们:

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    expect(screen.getByText('Search:')).toBeInTheDocument();
  });
});

如果你不是很清楚组件渲染后的HTML DOM树,建议你先使用debug查看树结构.然后再通过screen对象的搜索方法查找你需要的元素.

通常,如果没有找到元素,getByText会抛出错误,这样的错误提示会有助于让你知道,在你执行下一步的操作前,你没有正确的获取到你想要的元素.有的人也会使用此抛出错误的特性做隐式类型判断,但并不推荐这么用

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    // 隐式类型判断
    // because getByText would throw error
    // if element wouldn't be there
    screen.getByText('Search:');
 
    // 显式类型判断
    // recommended
    expect(screen.getByText('Search:')).toBeInTheDocument();
  });
});

getByText不仅可以接受字符串作为查询条件,也可以接受正则表达式.字符串参数用于完全匹配,而正则表达式用于部分匹配,在某些情况下这样会更加方便和灵活.

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    // fails
    expect(screen.getByText('Search')).toBeInTheDocument();
 
    // succeeds
    expect(screen.getByText('Search:')).toBeInTheDocument();
 
    // succeeds
    expect(screen.getByText(/Search/)).toBeInTheDocument();
  });
});

当然getByText只是众多搜索方法中的一种,其他的搜索方法,以及方法的优先及请参考后文应该使用哪个查询

Search variants

除了查询函数以外,还存在查询变体queryBy findBy.具体的方法如下:

  • queryByText
  • queryByRole
  • queryByLabelText
  • queryByPlaceholderText
  • queryByAltText
  • queryByDisplayValue
  • findByText
  • findByRole
  • findByLabelText
  • findByPlaceholderText
  • findByAltText
  • findByDisplayValue
getBy和queryBy的不同

在使用时最大的疑问通常是: 什么使用应该使用getBy,什么时候该使用其他的两个变体queryBy findBy

如果要判断一个元素不存在,并进行断言.这时候如果使用getBy就会导致测试报错.使用queryBy便能正常的进行.

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    screen.debug();
 
    // fails
    expect(screen.getByText(/Searches for JavaScript/)).toBeNull();
  });
});
import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
  });
});

findBy通常用于异步元素.在下面的例子中,在初始化的渲染后,组件会远程获取user的数据信息,获取到数据以后重新渲染组件,条件渲染部分就会渲染出Signed in as.

function getUser() {
  return Promise.resolve({ id: '1', name: 'Robin' });
}
 
function App() {
  const [search, setSearch] = React.useState('');
  const [user, setUser] = React.useState(null);
 
  React.useEffect(() => {
    const loadUser = async () => {
      const user = await getUser();
      setUser(user);
    };
 
    loadUser();
  }, []);
 
  function handleChange(event) {
    setSearch(event.target.value);
  }
 
  return (
    <div>
      {user ? <p>Signed in as {user.name}</p> : null}
 
      <Search value={search} onChange={handleChange}>
        Search:
      </Search>
 
      <p>Searches for {search ? search : '...'}</p>
    </div>
  );
}

如果我们想测试异步获取数据前后页面的变化,就可以使用findBy等待我们要更新的元素,不需要使用WaitFor.

import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', async () => {
    render(<App />);
 
    expect(screen.queryByText(/Signed in as/)).toBeNull();
 
    expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
  });
});

简而言之,getBy用于正常的查询元素,queryBy用于查询我们希望它不存在的元素并进行断言,findBy用于查询需要等待的异步元素.

Search multiple elements

如果要断言多个元素,可以使用多元素查询方法

  • getAllBy
  • queryAllBy
  • findAllBy
Assertive Functions

除了常见的Jest的断言函数,React Testing Library还提供了一些常用的断言函数,类似于上文中我们用到的toBeInTheDocument.

  • toBeDisabled
  • toBeEnabled
  • toBeEmpty
  • toBeEmptyDOMElement
  • toBeInTheDocument
  • toBeInvalid
  • toBeRequired
  • toBeValid
  • toBeVisible
  • toContainElement
  • toContainHTML
  • toHaveAttribute
  • toHaveClass
  • toHaveFocus
  • toHaveFormValues
  • toHaveStyle
  • toHaveTextContent
  • toHaveValue
  • toHaveDisplayValue
  • toBeChecked
  • toBePartiallyChecked
  • toHaveDescription

Fire event

到目前为止,我们只接触了测试当前组件是否渲染了某个元素.接下来说一下用户交互:

下面测试的场景是用户在input当中输入新的值,页面重新渲染,新的值会显示在页面上.

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    screen.debug();
 
    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });
 
    screen.debug();
  });
});

fireEvent函数的两个参数分别是,input元素和事件对象.screen.debug()输出键入新值以后渲染的HTML DOM树的变化,可以发现第二次的输出中包含了新的值.

此外,如果你的组件包含异步任务,比如在页面加载的一开始先请求用户信息,那么上面的测试代码就会提示下面的错误信息: "Warning: An update to App inside a test was not wrapped in act(...).".这代表这里有异步任务需要我们等待,需要先等异步人物执行完毕以后再进行其它的操作

describe('App', () => {
  test('renders App component', async () => {
    render(<App />);
 
    // wait for the user to resolve
    // needs only be used in our special case
    await screen.findByText(/Signed in as/);
 
    screen.debug();
 
    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });
 
    screen.debug();
  });
});

然后我们再针对input键入事件前后页面变化进行断言

describe('App', () => {
  test('renders App component', async () => {
    render(<App />);
 
    // wait for the user to resolve
    // needs only be used in our special case
    await screen.findByText(/Signed in as/);
 
    expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();
 
    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });
 
    expect(screen.getByText(/Searches for JavaScript/)).toBeInTheDocument();
  });
});

针对event的测试,官方更推荐使用使用,具体原因,看下文常见的错误使用方式

Callback handlers

回调函数的测试方式: mock回调函数,传给render的组件即可

function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input
        id="search"
        type="text"
        value={value}
        onChange={onChange}
      />
    </div>
  );
}
describe('Search', () => {
  test('calls the onChange callback handler', () => {
    const onChange = jest.fn();
 
    render(
      <Search value="" onChange={onChange}>
        Search:
      </Search>
    );
 
    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });
 
    expect(onChange).toHaveBeenCalledTimes(1);
  });
});

Asynchronous

下面的例子是一个远程获取数据以后展示在页面上的例子:

import React from 'react';
import axios from 'axios';
 
const URL = 'http://hn.algolia.com/api/v1/search';
 
function App() {
  const [stories, setStories] = React.useState([]);
  const [error, setError] = React.useState(null);
 
  async function handleFetch(event) {
    let result;
 
    try {
      result = await axios.get(`${URL}?query=React`);
 
      setStories(result.data.hits);
    } catch (error) {
      setError(error);
    }
  }
 
  return (
    <div>
      <button type="button" onClick={handleFetch}>
        Fetch Stories
      </button>
 
      {error && <span>Something went wrong ...</span>}
 
      <ul>
        {stories.map((story) => (
          <li key={story.objectID}>
            <a href={story.url}>{story.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
 
export default App;

单击按钮以后,请求开始.下面是对应的测试代码:

import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
import App from './App';
 
jest.mock('axios');
 
describe('App', () => {
  test('fetches stories from an API and displays them', async () => {
    const stories = [
      { objectID: '1', title: 'Hello' },
      { objectID: '2', title: 'React' },
    ];
 
    axios.get.mockImplementationOnce(() =>
      Promise.resolve({ data: { hits: stories } })
    );
 
    render(<App />);
 
    await userEvent.click(screen.getByRole('button'));
 
    const items = await screen.findAllByRole('listitem');
 
    expect(items).toHaveLength(2);
  });
});

在render组件以前,要先确保对http请求进行了mock处理,在进行请求时返回的便是我们的mock数据.

测试请求出错的代码:

import React from 'react';
import axios from 'axios';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
import App from './App';
 
jest.mock('axios');
 
describe('App', () => {
  test('fetches stories from an API and displays them', async () => {
    ...
  });
 
  test('fetches stories from an API and fails', async () => {
    axios.get.mockImplementationOnce(() =>
      Promise.reject(new Error())
    );
 
    render(<App />);
 
    await userEvent.click(screen.getByRole('button'));
 
    const message = await screen.findByText(/Something went wrong/);
 
    expect(message).toBeInTheDocument();
  });
});

React Router

待测试组件:

// app.js
import React from 'react'
import { Link, Route, Switch, useLocation } from 'react-router-dom'

const About = () => <div>You are on the about page</div>
const Home = () => <div>You are home</div>
const NoMatch = () => <div>No match</div>

export const LocationDisplay = () => {
  const location = useLocation()

  return <div data-testid="location-display">{location.pathname}</div>
}

export const App = () => (
  <div>
    <Link to="/">Home</Link>

    <Link to="/about">About</Link>

    <Switch>
      <Route exact path="/">
        <Home />
      </Route>

      <Route path="/about">
        <About />
      </Route>

      <Route>
        <NoMatch />
      </Route>
    </Switch>

    <LocationDisplay />
  </div>
)

测试代码:

// app.test.js
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMemoryHistory } from 'history'
import React from 'react'
import { Router } from 'react-router-dom'

import '@testing-library/jest-dom/extend-expect'

import { App, LocationDisplay } from './app'

test('full app rendering/navigating', () => {
  const history = createMemoryHistory()
  render(
    <Router history={history}>
      <App />
    </Router>
  )
  // verify page content for expected route
  // often you'd use a data-testid or role query, but this is also possible
  expect(screen.getByText(/you are home/i)).toBeInTheDocument()

  const leftClick = { button: 0 }
  userEvent.click(screen.getByText(/about/i), leftClick)

  // check that the content changed to the new page
  expect(screen.getByText(/you are on the about page/i)).toBeInTheDocument()
})

test('landing on a bad page', () => {
  const history = createMemoryHistory()
  history.push('/some/bad/route')
  render(
    <Router history={history}>
      <App />
    </Router>
  )

  expect(screen.getByText(/no match/i)).toBeInTheDocument()
})

test('rendering a component that uses useLocation', () => {
  const history = createMemoryHistory()
  const route = '/some-route'
  history.push(route)
  render(
    <Router history={history}>
      <LocationDisplay />
    </Router>
  )

  expect(screen.getByTestId('location-display')).toHaveTextContent(route)
})

React Redux

待测试组件:

import { connect } from 'react-redux'

const App = props => {
  return <div>{props.user}</div>
}

const mapStateToProps = state => {
  return state
}

export default connect(mapStateToProps)(App)

测试代码:

// test-utils.js
import React from 'react'
import { render as rtlRender } from '@testing-library/react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
// Import your own reducer
import reducer from '../reducer'

function render(
  ui,
  {
    initialState,
    store = createStore(reducer, initialState),
    ...renderOptions
  } = {}
) {
  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>
  }
  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}

// re-export everything
export * from '@testing-library/react'
// override render method
export { render }

React Testing Library 常见的错误使用方式

  1. Using cleanup

建议:don't use cleanup

大多数主流的测试框架现在都会进行自动清理工作,所以不再需要手动的清理行为

// bad
import { render, screen, cleanup } from "@testing-library/react";
afterEach(cleanup)

// good
import { render,screen } from "@testing-library/react";
  1. Not using screen

建议:use screen for querying and debugging

DOM Testing Library v6.11.0加入了screen,使用screen可以避免手动的添加和删除查询函数,你只需要使用screen,然后由编辑器帮你自动补全剩下的查询函数

// bad
const { getByRole } = render(<Example />);
const errorMesssageNode = getByRole("alert");

// good
render(<Example />)
const errorMessageNode = screen.getByRole("alert");
  1. Using the Wrong assertion

建议:install and use @testing-library/jest-dom

toBedisabled断言来自jest-dom。强烈建议使用jest-dom,因为这样收到的错误消息要好得多

const button = screen.getByRole("button, {name: /disabled button/i});

// bad
expect(button.disabled).toBe(true);
// error message:
// expect(received).toBe(expected) // Obejct.is equality
//
// Expected: true
// Received: false

// good
expect(button).toBeDisabled()
// error massage
// received element id not disabled
// <button />
  1. Wrapping things in act unnecessarily

建议:Learn when act is necessary and don't wrap things in act unnecessarily.

render fireEvent已经包含了act的功能,所以不需要再使用act

// bad
act(() => {
  render(<Example />)
});
const input = screen.getByRole('textbox', {name: /choose a fruit/i});
act(() => {
  fireEvent.keyDown(input, {key: 'ArrowDown'});
});
// good
render(<Example />);
const input = screen.getByRole('textbox', {name: /choose a fruit/i});
fireEvent.keyDown(input, {key: 'ArrowDown'});
  1. Using the wrong query

这是一个查询推荐顺序:应该使用哪个查询,使用最接近用户的方式进行查询

// bad
// assuming you've got this DOM to work with:
// <label>Username</label><input data-testid="username" />
screen.getByTestId('username');;

// good
// change the DOM to be accessible by associating the label and setting the type
// <label for="username">Username</label><input id="username" type="text" />
screen.getByRole('textbox', {name: /username/i});

// bad
const {container} = render(<Example />);
const button = container.querySelector('.btn-primary');
expect(button).toHaveTextContent(/click me/i);

// good
render(<Example />)
screen.getByRole('button', {name: /click me/i});

// bad
screen.getByTestId('submit-button');

// good
screen.getByRole('button', {name: /submit/i});
  1. Not using @testing-library/user-event

建议:Use @testing-library/user-event over fireEvent where possible.

@testing-library/user-event是一个基于fireEvent构建的,它提供了几种与用户交互更相似的方法。 在下面的示例中,fireEvent.change将只触发input上面的change事件。 但是userEvent.type还会触发keyDown,keyPress和keyUp事件。它更接近于用户的实际交互。

// bad
fireEvent.change(input, {target: {value: 'hello world'}});

// good
userEvent.type(input, 'hello world');
  1. Using query variants for anything except checking for non-existence*

建议:Only use the query* variants for asserting that an element cannot be found

类似于queryByRole这样的查询方法,只有在判断一个元素不存在于当前页面中时使用

// bad
expect(screen.queryByRole('alert')).toBeInTheDocument();

// good
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  1. Using waitFor to wait for elements that can be queried with find

下面这两段代码是等效的,但是第二个更简单,而且错误提示信息也会更好

// bad
const submitButton = await waitFor(() =>
  screen.getByRole('button', {name: /submit/i}),
)
// good
const submitButton = await screen.findByRole('button', {name: /submit/i})

应该使用哪个查询

根据指导原则,你的测试应该尽可能的类似于用户使用你的页面或组件。下面是推荐的有限顺序:

  1. Queries Accessible to Everyone(每个人都可以访问的查询)
  • getByRole

它可以用于查询处于accessibility tree中的所有元素,并且可以通过name选项过滤返回的元素。它应该是你查询的首选项。大多数情况下,它都会带着name选项一起使用,就像这样:getByRole('button', {name: /submit/i})。这里是Roles的列表可供参考Roles list

  • getByLabelText

这是查询表单元素的首选项

  • getByPlaceholderText

这是查询表单元素的一个代替方案

  • getByText

它对表单没有用,但这是找到大多数非交互式元素(例如div和spans)的第一方法

  • getByDisplayValue

2.Semantic Queries(语义查询)

  • getByAltText

如果你查询的元素支持alttext(例如img area input),那么可以使用它来进行查询

  • getByTitle

通过title属性查询,但是注意,一般通过屏幕查看页面的用户是无法直接看到titile的 3.Test IDs

  • getByTestId

它通常用于查询一些用户看不到听不到的内容,这些内容无法通过Role或者Text匹配到。

注意

虽然也可以使用querySelector DOM API进行查询,但是这是极其不推荐的做法,因为用户是看不到这些属性的。如果你不得不这样做的话,可以给它添加testid,像下面这样

// @testing-library/react
const { container } = render(<MyComponent />)
const foo = container.querySelector('[data-foo="bar"]')

有用的浏览器扩展

扩展工具Testing Playground可以帮助你找到最合适查询方式

Reference

react-testing-library

Which query should I use

Appearance and Disappearance

Considerations for fireEvent

Test for React Router

Test for React redux

Common mistakes with React Testing Library

How to use React Testing Library Tutorial