发布于 2025-12-10 3 阅读
0

现代 React 测试,第 3 部分:Jest 和 React 测试库

现代 React 测试,第 3 部分:Jest 和 React 测试库

React 测试库是一个用于测试 React 组件的小型库,它使我们在第一篇文章中学到的最佳实践应用变得容易。

这是系列文章的第三篇,我们将学习如何使用 Jest 和 React Testing Library 测试 React 组件。

Jest 和 React 测试库入门

我们将设置并使用这些工具:

  • Jest,一个测试运行器;
  • React 测试库,一个用于 React 的测试实用程序;

为什么选择 Jest 和 React 测试库

与其他测试运行器相比, Jest有许多优势:

  • 非常快。
  • 交互式监视模式仅运行与您的更改相关的测试。
  • 有用的失败消息。
  • 配置简单,甚至零配置。
  • 嘲讽和间谍。
  • 覆盖报告。
  • 丰富的匹配器 API

React Testing Library比 Enzyme 有一些优势:

  • 更简单的 API。
  • 方便的查询(表单标签、图像 alt、ARIA 角色)。
  • 异步查询和实用程序。
  • 更好的错误消息。
  • 更容易设置。
  • React 团队推荐

React 测试库可帮助您编写好的测试,并使编写糟糕的测试变得困难。

一些缺点可能是:

  • 如果您不同意本文中的某些最佳实践,Enzyme 可能是您的更好选择,因为它的 API 并不带有任何偏见。
  • React Testing Library 是一个新工具:它不太成熟,社区也比 Enzyme 小。

设置 Jest 和 React 测试库

首先,安装所有依赖项,包括对等依赖项:

npm install --save-dev jest @testing-library/react node-fetch
Enter fullscreen mode Exit fullscreen mode

您还需要babel-jest(用于 Babel)和ts-jest(用于 TypeScript)。如果您使用 webpack,请确保为环境启用 ECMAScript 模块转换test 。

创建一个src/setupTests.js文件来定制 Jest 环境:

// If you're using the fetch API
import fetch from 'node-fetch';
global.fetch = fetch;
Enter fullscreen mode Exit fullscreen mode

然后像这样更新package.json

{
  "name": "pizza",
  "version": "1.0.0",
  "dependencies": {
    "react": "16.9.0",
    "react-dom": "16.9.0"
  },
  "devDependencies": {
    "@testing-library/react": "^9.1.3",
    "jest": "24.9.0",
    "node-fetch": "2.6.0"
  },
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"]
  }
}
Enter fullscreen mode Exit fullscreen mode

setupFilesAfterEnv 选项告诉 Jest 我们在上一步中创建的安装文件。

创建我们的第一个测试

测试的最佳位置是靠近源代码。例如,如果你有一个位于 的组件src/components/Button.js,那么针对该组件的测试可以位于src/components/__tests__/Button.spec.js。Jest 会自动找到并运行该测试。

那么,让我们创建第一个测试:

import React from 'react';
import { render } from '@testing-library/react';

test('hello world', () => {
  const { getByText } = render(<p>Hello Jest!</p>);
  expect(getByText('Hello Jest!')).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

这里我们使用 React Testing Library 的render()方法渲染一段文本,然后使用 React Testing Library 的getByText()方法和 Jest 的assert测试包含“Hello Jest!”的段落是否被渲染toBeTruthy() 。

运行测试

运行npm test(或npm t)运行所有测试。你会看到类似这样的内容:

在终端中运行 Jest 和 React 测试库测试

运行npm run test:watch 以在监视模式下运行 Jest:Jest 将仅运行与自上次提交以来更改的文件相关的测试,并且每次您更改代码时,Jest 都会重新运行这些测试。这是我通常运行 Jest 的方式。即使在大型项目中,运行所有测试需要几分钟,监视模式也足够快。

运行npm run test:coverage 以运行所有测试并生成覆盖率报告。您可以在coverage文件夹中找到它。

快照测试

Jest 快照的工作方式如下:您告诉 Jest 您想要确保该组件的输出永远不会意外更改,并且 Jest 将您的组件输出(称为快照)保存到文件中:

exports[`test should render a label 1`] = `
<label
  className="isBlock">
  Hello Jest!
</label>
`;
Enter fullscreen mode Exit fullscreen mode

每次您或您团队中的某个人更改标记时,Jest 都会显示差异并要求更新快照(如果更改是有意为之)。

您可以使用快照来存储任何值:React 树、字符串、数字、对象等。

快照测试听起来是个好主意,但是存在几个问题

  • 容易提交有 bug 的快照;
  • 失败是难以理解的;
  • 一个小小的改变就可能导致数百个快照失败;
  • 我们倾向于不假思索地更新快照;
  • 与低级模块耦合;
  • 测试意图难以理解;
  • 它们给人一种虚假的安全感。

避免快照测试,除非您正在测试具有明确意图的非常简短的输出(例如类名或错误消息),或者您确实想要验证输出是否相同。

如果您使用快照,请保持快照简短并且优先toMatchInlineSnapshot()toMatchSnapshot()

例如,不是对整个组件输出进行快照:

test('shows out of cheese error message', () => {
  const { container } = render(<Pizza />);
  expect(container.firstChild).toMatchSnapshot();
});
Enter fullscreen mode Exit fullscreen mode

仅拍摄您正在测试的部分:

test('shows out of cheese error message', () => {
  const { getByRole } = render(<Pizza />);
  const error = getByRole('alert').textContent;
  expect(error).toMatchInlineSnapshot(`Error: Out of cheese!`);
});
Enter fullscreen mode Exit fullscreen mode

选择用于测试的 DOM 元素

一般来说,你的测试应该类似于用户与应用的交互方式。这意味着你应该避免依赖实现细节,因为它们可能会发生变化,你需要更新你的测试。

让我们比较一下选择 DOM 元素的不同方法:

选择器 受到推崇的 笔记
buttonButton 绝不 最差:太普通
.btn.btn-large 绝不 缺点:与风格相关
#main 绝不 坏处:一般情况下避免使用 ID
[data-testid="cookButton"] 有时 好的:对用户不可见,但不是实现细节,在没有更好的选择时使用
[alt="Chuck Norris"][role="banner"] 经常 优点:仍然对用户不可见,但已经是应用程序 UI 的一部分
[children="Cook pizza!"]  总是 最佳:应用程序 UI 的用户可见部分

总结一下:

  • 文本内容可能会发生变化,您需要更新测试。如果您的翻译库在测试中仅渲染字符串 ID,或者您希望测试处理用户在应用中看到的实际文本,那么这可能不是问题。
  • 测试 ID 会将仅在测试中需要的 prop 添加到你的标记中,使它们变得杂乱无章。此外,应用用户也看不到测试 ID:即使你从按钮上移除标签,带有测试 ID 的测试仍然会通过。你可能需要进行一些设置,将测试 ID 从发送给用户的标记中移除。

React 测试库提供了所有良好查询的方法。查询方法有六种变体

  • getBy*()返回第一个匹配的元素,当未找到元素或找到多个元素时抛出;
  • queryBy*()返回第一个匹配的元素但不会抛出;
  • findBy*()返回一个通过匹配元素解析的承诺,或者在默认超时后未找到元素或找到多个元素时拒绝;
  • getAllBy*(),,queryAllBy*()findAllBy*()与上面相同,但返回所有找到的元素,而不仅仅是第一个。

查询如下

  • getByLabelText()通过其查找表单元素<label>
  • getByPlaceholderText()通过占位符文本查找表单元素;
  • getByText()根据文本内容查找元素;
  • getByAltText()通过替代文本查找图像;
  • getByTitle()通过元素的属性查找元素title ;
  • getByDisplayValue()通过值查找表单元素;
  • getByRole()通过 ARIA 角色查找元素;
  • getByTestId()通过测试 ID 查找元素。

所有查询均有各种变体。例如,除了 之外getByLabelText()还有queryByLabelText()getAllByLabelText()queryAllByLabelText()findByLabelText()findAllByLabelText()

让我们看看如何使用查询方法。要在测试中选择此按钮:

<button data-testid="cookButton">Cook pizza!</button>
Enter fullscreen mode Exit fullscreen mode

我们可以通过其文本内容进行查询:

const { getByText } = render(<Pizza />);
getByText(/cook pizza!/i);
Enter fullscreen mode Exit fullscreen mode

请注意,我使用正则表达式(/cook pizza!/i)而不是字符串文字(’Cook pizza!’),以使查询对内容中的小调整和变化更具弹性。

或者通过测试ID查询:

const { getByTestId } = render(<Pizza />);
getByTestId('cookButton');
Enter fullscreen mode Exit fullscreen mode

两者都是有效的,并且都有各自的缺点:

  • 所有无关紧要的内容更改之后,您都需要更新测试。如果您的翻译库在测试中只渲染字符串 ID,那么这可能不是问题,因为即使文本更改后,只要整体含义相同,它们也会保持不变。
  • 测试 ID 会将仅在测试中需要的 prop 添加到你的标记中,使它们变得杂乱无章。你可能需要进行一些设置,将它们从发送给用户的标记中移除。

在测试中选择元素时,没有单一的完美方法,但有些方法比其他方法更好。

测试 React 组件

查看CodeSandbox 上的所有示例。遗憾的是,CodeSandbox 并不完全支持 Jest,因此某些测试会失败,除非您克隆GitHub 存储库并在本地运行测试。

测试渲染

当您的组件有多个变体并且您想要测试某个 prop 是否呈现正确的变体时,这种测试会很有用。

import React from 'react';
import { render } from '@testing-library/react';
import Pizza from '../Pizza';

test('contains all ingredients', () => {
  const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];
  const { getByText } = render(<Pizza ingredients={ingredients} />);

  ingredients.forEach(ingredient => {
    expect(getByText(ingredient)).toBeTruthy();
  });
});
Enter fullscreen mode Exit fullscreen mode

在这里,我们正在测试我们的Pizza组件是否将传递给组件的所有成分作为 prop 进行渲染。

测试用户交互

click要模拟类似或 的事件change,请使用fireEvent.*()方法,然后测试输出:

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ExpandCollapse from '../ExpandCollapse';

test('button expands and collapses the content', () => {
  const children = 'Hello world';
  const { getByText, queryByText } = render(
    <ExpandCollapse excerpt="Information about dogs">
      {children}
    </ExpandCollapse>
  );

  expect(queryByText(children)).not.toBeTruthy();

  fireEvent.click(getByText(/expand/i));

  expect(queryByText(children)).toBeTruthy();

  fireEvent.click(getByText(/collapse/i));

  expect(queryByText(children)).not.toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

这里我们有一个组件,点击“展开”按钮时会显示一些文本,点击“折叠”按钮时会隐藏文本。我们的测试验证了这一行为。

我们使用queryByText() 方法而不是getByText()因为前者在未找到元素时不会抛出:这样我们就可以测试元素是否存在。

请参阅下一节,了解更复杂的测试事件示例。

测试事件处理程序

当你对单个组件进行单元测试时,事件处理程序通常在父组件中定义,并且响应这些事件时不会出现可见的变化。它们还定义了要测试的组件的 API。

jest.fn()创建一个模拟函数间谍函数,使您可以检查该函数被调用的次数以及调用了哪些参数。

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Login from '../Login';

test('submits username and password', () => {
  const username = 'me';
  const password = 'please';
  const onSubmit = jest.fn();
  const { getByLabelText, getByText } = render(
    <Login onSubmit={onSubmit} />
  );

  fireEvent.change(getByLabelText(/username/i), {
    target: { value: username }
  });

  fireEvent.change(getByLabelText(/password/i), {
    target: { value: password }
  });

  fireEvent.click(getByText(/log in/i));

  expect(onSubmit).toHaveBeenCalledTimes(1);
  expect(onSubmit).toHaveBeenCalledWith({
    username,
    password
  });
});
Enter fullscreen mode Exit fullscreen mode

这里我们使用定义组件prop 的jest.fn() 间谍,然后使用上一节描述的技术填写表单,然后模拟单击提交按钮并检查该函数是否仅被调用一次并且是否已收到登录名和密码。onSubmitLoginonSubmit

与 Enzyme 相比,我们无需直接调用表单提交处理程序。React Testing Library 的fireEvent.click()方法会在 DOM 节点上派发一个 click 事件,该事件会被 React 捕获并处理,就像处理普通点击一样。例如,当我们“点击” a 时,它会派发表单提交事件<button type="submit">,而当我们“点击” a 时则不会派发该事件<button type="button">,这使得我们的测试更加可靠。

异步测试

异步操作是最难测试的。开发人员常常会放弃,并在测试中添加随机延迟:

const wait = (time = 0) =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

test('something async', async () => {
  // Run an async operation...
  await wait(100).then(() => {
    expect(getByText('Done!')).toBeTruthy();
  });
});
Enter fullscreen mode Exit fullscreen mode

这种方法存在问题。延迟始终是一个随机数。在开发人员编写代码时,这个数字在开发人员的机器上足够好。但在其他时间、在任何其他机器上,它都可能太长或太短。如果延迟太长,我们的测试将运行超过必要时间。如果延迟太短,我们的测试就会中断。

更好的方法是轮询:等待期望的结果,例如页面上的新文本,通过短间隔多次检查,直到期望结果为真。React 测试库提供了一些工具来实现这一点。首先是一个通用wait()方法(还有一些其他方法适用于更具体的用例):

import { wait } from '@testing-library/react';

test('something async', async () => {
  // Run an async operation...
  await wait(() => {
    expect(getByText('Done!')).toBeTruthy();
  });
});
Enter fullscreen mode Exit fullscreen mode

但是对于查询元素,我们可以使用findBy*()等待findAllBy*()元素出现的方法:

test('something async', async () => {
  expect.assertions(1);
  // Run an async operation...
  expect(await findByText('Done!')).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

现在我们的测试将等待必要的时间,但不会超过时间。

expect.assertions()方法对于编写异步测试很有用:你告诉 Jest 你的测试中有多少个断言,如果你搞砸了某些事情,比如忘记返回一个 Promise test(),那么这个测试就会失败。

请参阅下一节以了解更多现实示例。

测试网络请求和模拟

有很多方法可以测试发送网络请求的组件:

  • 依赖注入;
  • 模拟服务模块;
  • 模拟高级网络 API,例如fetch
  • 模拟低级网络 API,捕获所有发出网络请求的方式。

我这里不建议向真实的 API 发送真实的网络请求,因为这种方式既慢又脆弱。API 返回的任何网络问题或数据变更都可能破坏我们的测试。此外,你需要为所有测试用例准备正确的数据——这在真实的 API 或数据库中很难实现。

依赖注入是指将依赖项作为函数参数或组件属性传递,而不是将其硬编码在模块内部。这允许您在测试中传递另一个实现。使用默认函数参数或默认组件属性来定义默认实现,该实现应该在非测试代码中使用。这样,您就不必在每次使用函数或组件时都传递依赖项:

import React from 'react';

const defaultFetchIngredients = () => fetch(URL).then(r => r.json());

export default function RemotePizza({ fetchIngredients }) {
  const [ingredients, setIngredients] = React.useState([]);

  const handleCook = () => {
    fetchIngredients().then(response => {
      setIngredients(response.args.ingredients);
    });
  };

  return (
    <>
      <button onClick={handleCook}>Cook</button>
      {ingredients.length > 0 && (
        <ul>
          {ingredients.map(ingredient => (
            <li key={ingredient}>{ingredient}</li>
          ))}
        </ul>
      )}
    </>
  );
}

RemotePizza.defaultProps = {
  fetchIngredients: defaultFetchIngredients
};
Enter fullscreen mode Exit fullscreen mode

当我们使用组件而不传递fetchIngredientsprop 时,它将使用默认实现:

<RemotePizza />
Enter fullscreen mode Exit fullscreen mode

但在测试中,我们将传递一个自定义实现,它返回模拟数据而不是发出实际的网络请求:

import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import RemotePizza from '../RemotePizza';

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

test('download ingredients from internets', async () => {
  expect.assertions(4);

  const fetchIngredients = () =>
    Promise.resolve({
      args: { ingredients }
    });
  const { getByText } = render(
    <RemotePizza fetchIngredients={fetchIngredients} />
  );

  fireEvent.click(getByText(/cook/i));

  await wait(() => {
    ingredients.forEach(ingredient => {
      expect(getByText(ingredient)).toBeTruthy();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

当您渲染直接接受注入的组件时,依赖注入非常适合单元测试,但对于集成测试,需要太多样板来将依赖项传递给深度嵌套的组件。

这就是请求模拟的用武之地。

模拟类似于依赖注入,因为你也在测试中用自己的依赖实现替换依赖实现,但它的工作层次更深:通过修改模块加载或浏览器 API(如)的fetch工作方式。

jest.mock()可以模拟任何 JavaScript 模块。为了使其在我们的例子中正常工作,我们需要将获取函数提取到一个单独的模块中,通常称为服务模块

export const fetchIngredients = () =>
  fetch(
    'https://httpbin.org/anything?ingredients=bacon&ingredients=mozzarella&ingredients=pineapples'
  ).then(r => r.json());
Enter fullscreen mode Exit fullscreen mode

然后将其导入到组件中:

import React from 'react';
import { fetchIngredients } from '../services';

export default function RemotePizza() {
  /* Same as above */
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以在测试中模拟它:

import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import RemotePizza from '../RemotePizza';
import { fetchIngredients } from '../../services';

jest.mock('../../services');

afterEach(() => {
  fetchIngredients.mockReset();
});

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

test('download ingredients from internets', async () => {
  expect.assertions(4);

  fetchIngredients.mockResolvedValue({ args: { ingredients } });

  const { getByText } = render(<RemotePizza />);

  fireEvent.click(getByText(/cook/i));

  await wait(() => {
    ingredients.forEach(ingredient => {
      expect(getByText(ingredient)).toBeTruthy();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

我们正在使用 Jest 的mockResolvedValue方法来解析具有模拟数据的 Promise。

模拟fetch API类似于模拟方法,但不是导入方法并用 模拟它jest.mock(),而是匹配 URL 并给出模拟响应。

我们将使用fetch-mock来模拟 API 请求:

import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import RemotePizza from '../RemotePizza';

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

afterAll(() => {
  fetchMock.restore();
});

test('download ingredients from internets', async () => {
  expect.assertions(4);

  fetchMock.restore().mock(/https:\/\/httpbin.org\/anything\?.*/, {
    body: { args: { ingredients } }
  });

  const { getByText } = render(<RemotePizza />);

  fireEvent.click(getByText(/cook/i));

  await wait(() => {
    ingredients.forEach(ingredient => {
      expect(getByText(ingredient)).toBeTruthy();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

这里我们使用mock()fetch-mock 中的方法,对任何与给定 URL 模式匹配的网络请求返回一个模拟响应。其余测试与依赖注入相同。

模拟网络类似于模拟fetch API,但它在较低级别上工作,因此使用其他 API(如XMLHttpRequest)发送的网络请求也将被模拟。

我们将使用Nock来模拟网络请求:

import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import nock from 'nock';
import RemotePizza from '../RemotePizza';

const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];

afterEach(() => {
  nock.restore();
});

test('download ingredients from internets', async () => {
  expect.assertions(5);

  const scope = nock('https://httpbin.org')
    .get('/anything')
    .query(true)
    .reply(200, { args: { ingredients } });

  const { getByText } = render(<RemotePizza />);

  fireEvent.click(getByText(/cook/i));

  expect(scope.isDone()).toBe(true);

  await wait(() => {
    ingredients.forEach(ingredient => {
      expect(getByText(ingredient)).toBeTruthy();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

代码与 fetch-mock 几乎相同,但这里我们定义了一个范围:请求 URL 和模拟响应的映射。

query(true)表示我们正在匹配具有任何查询参数的请求,否则您可以定义特定的参数,例如query({quantity: 42})

scope.isDone()true 范围内定义的所有请求都已提出。

我会在jest.mock()和 Nock 之间进行选择:

  • jest.mock() 已经可以通过 Jest 使用了,您不需要设置和学习任何新内容 - 它的工作方式与模拟任何其他模块相同。
  • Nock 的 API 比 fetch-mock 更便捷,并且提供了调试工具。它还可以记录真实的网络请求,因此您无需手动编写模拟响应。

调试

有时您想检查渲染的 React 树,请使用debug()方法:

const { debug } = render(<p>Hello Jest!</p>);
debug();
// -> <p>Hello Jest!</p>
Enter fullscreen mode Exit fullscreen mode

您还可以打印元素:

debug(getByText(/expand/i));
Enter fullscreen mode Exit fullscreen mode

结论

我们已经学习了如何设置 React 测试库以及如何测试不同的 React 组件。


感谢 Joe Boyle、Kent C. Dodds、Anna Gerus、Patrick Hund、Monica Lent、Morgan Packard、Alexander Plavinski、Giorgio Polvara、Juho Vepsäläinen。

鏂囩珷鏉ユ簮锛�https://dev.to/sapegin/modern-react-testing-part-3-jest-and-react-testing-library-3n0i