阅读 2444

React Testing Library使用总结

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

文章分类
前端
文章标签