如何使用React Testing Library和Jest测试React应用

10,034 阅读14分钟

原文链接:How to Start Testing Your React Apps Using the React Testing Library and Jest

写测试通常都会被认作一个乏味的过程,但是这是你必须掌握的一个技能,虽然在某些时候,测试并不是必要的。然后对于大多数有追求的公司而言,单元测试是必须的,开发者对于代码的自信会大幅提高,侧面来说也能提高公司对其产品的信心,也能让用户使用得更安心。

在 React 世界中,我们使用 react-testing-libraryjest 配合使用来测试我们的 React Apps。

在本文中,我将向你介绍如何使用 8 种简单的方式来来测试你的 React App。

先备条件

本教程假定你对 React 有一定程度的了解,本教程只会专注于单元测试。

接下来,在终端中运行以下命令来克隆已经集成了必要插件的项目:

git clone https://github.com/ibrahima92/prep-react-testing-library-guide

安装依赖:

npm install

或者使用 Yarn :

yarn

好了,就这些,现在让我们了解一些基础知识!

基础知识

本文将大量使用一些关键内容,了解它们的作用可以帮助你快速理解。

it 或 test :用于描述测试本身,其包含两个参数,第一个是该测试的描述,第二个是执行测试的函数。

expect :表示测试需要通过的条件,它将接收到的参数与 matcher  进行比较。

matcher :一个希望到达预期条件的函数,称其为匹配器。

render :用于渲染给定组件的方法。

import React from "react";
import { render } from "@testing-library/react";
import App from "./App";

it("should take a snapshot", () => {
  const { asFragment } = render(<App />);

  expect(asFragment(<App />)).toMatchSnapshot();
});

如上所示,我们使用 it  来描述一个测试,然后使用 render  方法来显示 App 这个组件,同时还期待的是 asFragment(<App />)  的结果与 toMatchSnapshot()  这个 matcher  匹配(由 jest 提供的匹配器)。

顺便说一句, render  方法返回了几种我们可以用来测试功能的方法,我们还使用了对象解构来获取到某个方法。

那么,让我们继续并在下一节中进一步了解 React Testing Library 吧~ 。

什么是 React Testing Library ?

React Testing Library 是用于测试 React 组件的非常便捷的解决方案。 它在 react-dom  和 react-dom/test-utils  之上提供了轻量且实用的 API,如果你打开 React 官网中的测试工具推荐,你会发现 Note 中写了:

注意: 我们推荐使用 React Testing Library,它使得针对组件编写测试用例就像终端用户在使用它一样方便。

React Testing Library 是一个 DOM 测试库,这意味着它并不会直接处理渲染的 React 组件实例,而是处理 DOM 元素以及它们在实际用户面前的行为。

这是一个很棒的库,(相对)易于使用,并且鼓励良好的测试实践。 当然,你也可以在没有 Jest 的情况下使用它。

“你的测试与软件的使用方式越接近,就能越给你信心。”

那么,让我们在下一部分中就开始使用它吧。顺便说一下,你不需要安装任何依赖了,刚才克隆的项目本身是用 create-react-app  创建的,已经集成了编写单元测试所需要的插件了,只需保证你已经安装了依赖即可。

8 个示例

1.如何创建测试快照

顾名思义,快照使我们可以保存给定组件的快照。 当你对组件进行一些更新或重构,希望获取或比较更改时,它会很有帮助。

现在,让我们对 App.js  文件进行快照测试。

  • App.test.js
import React from "react";
import { render, cleanup } from "@testing-library/react";
import App from "./App";

afterEach(cleanup);

it("should take a snapshot", () => {
  const { asFragment } = render(<App />);

  expect(asFragment(<App />)).toMatchSnapshot();
});

要获得快照,我们首先需要导入 render  和 cleanup  方法。 在本文中,我们将经常使用这两种方法。

你大概也猜到了, render  方法用于渲染 React 组件, cleanup  方法将作为参数传递给 afterEach ,目的是在每个测试完成后清除所有内容,以避免内存泄漏。

接下来,我们可以使用 render  渲染 App 组件,并从该方法返回 asFragment 。 最后,确保 App 组件的片段与快照匹配。

现在,要运行测试,请打开终端并导航到项目的根目录,然后运行以下命令:

yarn test

如果你使用 NPM:

npm run test

结果,它将在 src  中创建一个新文件夹 __snapshots__  和及其目录下新建一个 App.test.js.snap  文件,如下所示:

  • App.test.js.snap :
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should take a snapshot 1`] = `
<DocumentFragment>
  <div class="App">
    <h1>
      Testing Updated
    </h1>
  </div>
</DocumentFragment>
`;

如果现在你对 App.js  进行更改,则测试将失败,因为快照将不再符合条件。要使其通过,只需按键盘上的 u  健即可对其进行更新。 并且你将在 App.test.js.snap  中拥有更新后的快照。

现在,让我们继续并开始测试我们的元素。

2.测试 DOM 元素

为了测试我们的 DOM 元素,我们先大概看下 components/TestElements.js  文件。

  • TestElements.js :
import React from "react";

const TestElements = () => {
  const [counter, setCounter] = React.useState(0);

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={() => setCounter(counter + 1)}>Up</button>
      <button
        disabled
        data-testid="button-down"
        onClick={() => setCounter(counter - 1)}
      >
        Down
      </button>
    </>
  );
};

export default TestElements;

你唯一需要留意的就是 data-testid 。 它将用于从测试文件中获取到这些 dom 元素。 现在,让我们编写单元测试:

测试计数器(counter)是否等于 0

  • TestElements.test.js :
import React from "react";
import { render, cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import TestElements from "./TestElements";

afterEach(cleanup);

it("should equal to 0", () => {
  const { getByTestId } = render(<TestElements />);
  expect(getByTestId("counter")).toHaveTextContent(0);
});

如你所见,语法其实和先前的快照测试非常相似。唯一的区别是,我们现在使用 getByTestId  进行 dom 元素的获取,然后检查该元素的文本内容是否为 0 。

测试 button 按钮是禁用还是启用

  • TestElements.test.js (将以下代码追加到该文件中):
it("should be enabled", () => {
  const { getByTestId } = render(<TestElements />);
  expect(getByTestId("button-up")).not.toHaveAttribute("disabled");
});

it("should be disabled", () => {
  const { getByTestId } = render(<TestElements />);
  expect(getByTestId("button-down")).toBeDisabled();
});

同样地,我们使用 getByTestId  来获取 dom 元素,第一个测试是测试 button 元素上没有属性 disabled ;第二个测试是测试 button 元素处于禁用状态。

保存之后再运行测试命令,你会发现测试全部通过了!

恭喜你成功通过了自己的第一个测试!

现在,让我们在下一部分中学习如何测试事件。

3.测试事件

在写单元测试之前,我们先来看看 components/TestEvents.js  文件是啥样:

  • TestEvents.js :
import React from "react";

const TestEvents = () => {
  const [counter, setCounter] = React.useState(0);

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={() => setCounter(counter + 1)}>
        Up
      </button>
      <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>
        Down
      </button>
    </>
  );
};

export default TestEvents;

现在,让我们为这个组件写单元测试。

单击按钮时,测试计数器是否正确递增和递减

  • TestEvents.test.js :
import React from "react";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import TestEvents from "./TestEvents";

afterEach(cleanup);

it("increments counter", () => {
  const { getByTestId } = render(<TestEvents />);

  fireEvent.click(getByTestId("button-up"));

  expect(getByTestId("counter")).toHaveTextContent("1");
});

it("decrements counter", () => {
  const { getByTestId } = render(<TestEvents />);

  fireEvent.click(getByTestId("button-down"));

  expect(getByTestId("counter")).toHaveTextContent("-1");
});

如你所见,除了预期的文本内容不同之外,这两个测试非常相似。

第一个测试使用 fireEvent.click()  触发 click 事件,以检查单击按钮时计数器是否增加为 1 。

第二个测试检查单击按钮时计数器是否递减到 -1 。

fireEvent  有几种可用于测试事件的方法,因此请随时阅读文档以了解更多信息。

现在我们知道了如何测试事件,让我们继续学习下一节如何处理异步操作。

4.测试异步操作

异步操作需要花费一些时间才能完成。它可以是 HTTP 请求,计时器等。

同样地,让我们检查一下 components/TestAsync.js  文件。

  • TestAsync.js :
import React from "react";

const TestAsync = () => {
  const [counter, setCounter] = React.useState(0);

  const delayCount = () =>
    setTimeout(() => {
      setCounter(counter + 1);
    }, 500);

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={delayCount}>
        Up
      </button>
      <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>
        Down
      </button>
    </>
  );
};

export default TestAsync;

在这里,我们使用 setTimeout()  模拟异步。

测试计数器是否在 0.5s 后递增

  • TestAsync.test.js :
import React from "react";
import {
  render,
  cleanup,
  fireEvent,
  waitForElement,
} from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import TestAsync from "./TestAsync";

afterEach(cleanup);

it("increments counter after 0.5s", async () => {
  const { getByTestId, getByText } = render(<TestAsync />);

  fireEvent.click(getByTestId("button-up"));

  const counter = await waitForElement(() => getByText("1"));

  expect(counter).toHaveTextContent("1");
});

为了测试递增事件,我们首先必须使用 async/await  来处理该动作,因为正如我之前所说的,它需要一段时间之后才能完成。

随着我们使用了一个新的辅助方法 getByText() ,这与 getByTestId()  相似,只是现在我们通过 dom 元素的文本内容去获取该元素而已,而不是之前使用的 test-id 。

现在,单击按钮后,我们等待使用 waitForElement(() => getByText('1'))  递增计数器。 计数器增加到 1  后,我们现在可以移至条件并检查计数器是否有效等于 1 。

是不是理解起来很简单?话虽如此,让我们现在转到更复杂的测试用例。

你准备好了吗?

5.测试 React Redux

如果您不熟悉 React Redux,本文可能会为你提供些许帮助。先让我们看一下 components/TestRedux.js  的内容。

  • TestRedux.js :
import React from "react";
import { connect } from "react-redux";

const TestRedux = ({ counter, dispatch }) => {
  const increment = () => dispatch({ type: "INCREMENT" });
  const decrement = () => dispatch({ type: "DECREMENT" });

  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={increment}>
        Up
      </button>
      <button data-testid="button-down" onClick={decrement}>
        Down
      </button>
    </>
  );
};

export default connect((state) => ({ counter: state.count }))(TestRedux);

再看看 store/reducer.js :

export const initialState = {
  count: 0,
};

export function reducer(state = initialState, action) {
  switch (action.type) {
    case "INCREMENT":
      return {
        count: state.count + 1,
      };
    case "DECREMENT":
      return {
        count: state.count - 1,
      };
    default:
      return state;
  }
}

如你所见,没有什么花哨的东西 - 它只是由 React Redux 处理的基本计数器组件。

现在,让我们编写单元测试。

测试初始状态是否等于 0

  • TestRedux.test.js :
import React from "react";
import { createStore } from "redux";
import { Provider } from "react-redux";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { initialState, reducer } from "../store/reducer";
import TestRedux from "./TestRedux";

const renderWithRedux = (
  component,
  { initialState, store = createStore(reducer, initialState) } = {}
) => {
  return {
    ...render(<Provider store={store}>{component}</Provider>),
    store,
  };
};

afterEach(cleanup);

it("checks initial state is equal to 0", () => {
  const { getByTestId } = renderWithRedux(<TestRedux />);
  expect(getByTestId("counter")).toHaveTextContent("0");
});

我们需要导入一些内容来测试 React Redux。在这里,我们创建了自己的辅助函数 renderWithRedux()  来渲染组件,因为它将多次被使用到。

renderWithRedux()  接收要渲染的组件, initialState  和 store  作为参数。如果没有 store ,它将创建一个新 store ,如果没有收到 initialState  或 store ,则将返回一个空对象。

接下来,我们使用 render()  渲染组件并将 store  传递给 Provider 。

意味着,我们现在可以将组件 TestRedux  传递给 renderWithRedux()  来测试计数器是否等于 0 。

测试计数器是否正确递增和递减

  • TestRedux.test.js (将以下代码追加到该文件中):
it("increments the counter through redux", () => {
  const { getByTestId } = renderWithRedux(<TestRedux />, {
    initialState: { count: 5 },
  });
  fireEvent.click(getByTestId("button-up"));
  expect(getByTestId("counter")).toHaveTextContent("6");
});

it("decrements the counter through redux", () => {
  const { getByTestId } = renderWithRedux(<TestRedux />, {
    initialState: { count: 100 },
  });
  fireEvent.click(getByTestId("button-down"));
  expect(getByTestId("counter")).toHaveTextContent("99");
});

为了测试递增和递减事件,我们将initialState 作为第二个参数传递给 renderWithRedux() 。 现在,我们可以单击按钮并测试预期结果是否符合条件。

现在,让我们进入下一部分并介绍 React Context。

再接下来是 React Router 和 Axios,你还会看下去吗?

6.测试 React Context

如果您不熟悉 React Context,请先阅读本文。另外,让我们看下 components/TextContext.js  文件。

  • TextContext.js :
import React, { createContext, useContext, useState } from "react";

export const CounterContext = createContext();

const CounterProvider = () => {
  const [counter, setCounter] = useState(0);
  const increment = () => setCounter(counter + 1);
  const decrement = () => setCounter(counter - 1);

  return (
    <CounterContext.Provider value={{ counter, increment, decrement }}>
      <Counter />
    </CounterContext.Provider>
  );
};

export const Counter = () => {
  const { counter, increment, decrement } = useContext(CounterContext);
  return (
    <>
      <h1 data-testid="counter">{counter}</h1>
      <button data-testid="button-up" onClick={increment}>
        Up
      </button>
      <button data-testid="button-down" onClick={decrement}>
        Down
      </button>
    </>
  );
};

export default CounterProvider;

现在计数器状态通过 React Context 进行管理,让我们编写单元测试以检查其行为是否符合预期。

测试初始状态是否等于 0

  • TestContext.test.js :
import React from "react";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import CounterProvider, { CounterContext, Counter } from "./TestContext";

const renderWithContext = (component) => {
  return {
    ...render(
      <CounterProvider value={CounterContext}>{component}</CounterProvider>
    ),
  };
};

afterEach(cleanup);

it("checks if initial state is equal to 0", () => {
  const { getByTestId } = renderWithContext(<Counter />);
  expect(getByTestId("counter")).toHaveTextContent("0");
});

与上一节关于 React Redux 的部分一样,这里我们通过创建一个辅助函数 renderWithContext()  来渲染组件。但是这次,它仅接收组件作为参数。 为了创建一个新的上下文,我们将 CounterContext  传递给 Provider。

现在,我们就可以测试计数器初始状态是否等于 0 。

测试计数器是否正确递增和递减

  • TestContext.test.js (将以下代码追加到该文件中):
it("increments the counter", () => {
  const { getByTestId } = renderWithContext(<Counter />);

  fireEvent.click(getByTestId("button-up"));
  expect(getByTestId("counter")).toHaveTextContent("1");
});

it("decrements the counter", () => {
  const { getByTestId } = renderWithContext(<Counter />);

  fireEvent.click(getByTestId("button-down"));
  expect(getByTestId("counter")).toHaveTextContent("-1");
});

如你所见,这里我们触发一个 click 事件,测试计数器是否正确地增加到 1  或减少到 -1 。

我们现在可以进入下一节并介绍 React Router。

7.测试 React Router

如果您想深入研究 React Router,这篇文章可能会对你有所帮助。现在,让我们先 components/TestRouter.js  文件。

  • TestRouter.js :
import React from "react";
import { Link, Route, Switch, useParams } from "react-router-dom";

const About = () => <h1>About page</h1>;
const Home = () => <h1>Home page</h1>;
const Contact = () => {
  const { name } = useParams();

  return <h1 data-testid="contact-name">{name}</h1>;
};

const TestRouter = () => {
  const name = "John Doe";

  return (
    <>
      <nav data-testid="navbar">
        <Link data-testid="home-link" to="/">
          Home
        </Link>
        <Link data-testid="about-link" to="/about">
          About
        </Link>
        <Link data-testid="contact-link" to={`/contact/${name}`}>
          Contact
        </Link>
      </nav>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
        <Route path="/about:name" component={Contact} />
      </Switch>
    </>
  );
};

export default TestRouter;

在这里,我们有一些导航主页时想要渲染的组件。

测试导航切换时是否正确渲染

  • TestRouter.test.js :
import React from "react";
import { Router } from "react-router-dom";
import { render, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { createMemoryHistory } from "history";
import TestRouter from "./TestRouter";

const renderWithRouter = (component) => {
  const history = createMemoryHistory();
  return {
    ...render(<Router history={history}>{component}</Router>),
  };
};

it("should render the home page", () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />);
  const navbar = getByTestId("navbar");
  const link = getByTestId("home-link");

  expect(container.innerHTML).toMatch("Home page");
  expect(navbar).toContainElement(link);
});

要测试 React Router,我们首先必须有一个导航 history。因此,我们使用 createMemoryHistory()  来创建导航 history 。

接下来,我们使用辅助函数 renderWithRouter()  渲染组件并将 history  传递给 Router  组件。 这样,我们现在可以测试在开始时加载的页面是否是主页,并在导航栏中渲染预期中的 Link  组件。

单击链接时,测试它是否导航到其他页面

  • TestRouter.test.js (将以下代码追加到该文件中):
it("should navigate to the about page", () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />);

  fireEvent.click(getByTestId("about-link"));
  expect(container.innerHTML).toMatch("About page");
});

it("should navigate to the contact page with the params", () => {
  const { container, getByTestId } = renderWithRouter(<TestRouter />);

  fireEvent.click(getByTestId("contact-link"));
  expect(container.innerHTML).toMatch("John Doe");
});

要检查导航是否有效,我们必须在导航链接上触发 click 事件。

对于第一个测试,我们检查内容是否与“About Page”中的文本相等,对于第二个测试,我们测试路由参数并检查其是否正确传递。

现在,我们可以转到最后一节,学习如何测试 Axios 请求。

我们快完成了!加油啊!

8.测试 HTTP Request

像往常一样,让我们首先看一下 components/TextAxios.js  文件内容。

  • TestAxios.js :
import React from "react";
import axios from "axios";

const TestAxios = ({ url }) => {
  const [data, setData] = React.useState();

  const fetchData = async () => {
    const response = await axios.get(url);
    setData(response.data.greeting);
  };

  return (
    <>
      <button onClick={fetchData} data-testid="fetch-data">
        Load Data
      </button>
      {data ? (
        <div data-testid="show-data">{data}</div>
      ) : (
        <h1 data-testid="loading">Loading...</h1>
      )}
    </>
  );
};

export default TestAxios;

如你所见,我们有一个简单的组件,该组件带有一个用于发出请求的按钮。并且如果数据不可用,它将显示一条加载中的消息(Loading...)。

现在,让我们编写测试。

测试是否已正确提取和显示数据

  • TestAxios.test.js :
import React from "react";
import { render, waitForElement, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import axiosMock from "axios";
import TestAxios from "./TestAxios";

jest.mock("axios");

it("should display a loading text", () => {
  const { getByTestId } = render(<TestAxios />);

  expect(getByTestId("loading")).toHaveTextContent("Loading...");
});

it("should load and display the data", async () => {
  const url = "/greeting";
  const { getByTestId } = render(<TestAxios url={url} />);

  axiosMock.get.mockResolvedValueOnce({
    data: { greeting: "hello there" },
  });

  fireEvent.click(getByTestId("fetch-data"));

  const greetingData = await waitForElement(() => getByTestId("show-data"));

  expect(axiosMock.get).toHaveBeenCalledTimes(1);
  expect(axiosMock.get).toHaveBeenCalledWith(url);
  expect(greetingData).toHaveTextContent("hello there");
});

这个测试用例有些不同,因为我们必须处理一个 HTTP 请求。为此,我们必须借助 jest.mock('axios')  模拟 axios 请求。

现在,我们可以使用 axiosMock  并对其应用 get()  方法。最后,我们将使用 Jest 的内置函数 mockResolvedValueOnce()  将模拟数据作为参数传递。

对于第二个测试,我们可以单击按钮来获取数据,所以需要使用 async/await  来处理异步请求。现在我们必须保证以下 3  个测试通过:

  • HTTP 请求执行了正确的次数?
  • HTTP 请求是否已通过 url 完成?
  • 获取的数据是否符合期望?

对于第一个测试,我们只检查没有数据要显示时是否显示加载消息(loading...)。

到现在为止,我们现在已经使用了 8  个简单步骤完成了 React Apps 的测试了。

推荐阅读

现在的你是否已经感觉入门了呢?请查阅更多文档信息进阶吧,以下是一些推荐阅读:

官方文档

React Testing Library docs
React Testing Library Cheatsheet
Jest DOM matchers cheatsheet
Jest Docs

基础入门

Testing with react-testing-library and Jest

前端自动化测试 jest 教程 1-配置安装
前端自动化测试 jest 教程 2-匹配器 matchers
前端自动化测试 jest 教程 3-命令行工具
前端自动化测试 jest 教程 4-异步代码测试
前端自动化测试 jest 教程 5-钩子函数
前端自动化测试 jest 教程 6-mock 函数
前端自动化测试 jest 教程 7-定时器测试
前端自动化测试 jest 教程 8-snapshot 快照测试

写在最后

React Testing Library 是用于测试 React 组件的出色插件包。它使我们能够访问 jest-dom 的 matcher,我们可以使用它们来更有效地并通过良好实践来测试我们的组件,希望本文对你有所帮助。

感谢您阅读!

这是我的 github/blog,若对你有所帮助,赏个小小的 star 🌟 咯~