如何使用Jest和React测试库测试React应用程序

作者选择了 Vets Who Code 作为 Write for DOnations 计划的捐赠对象。

介绍

获得可靠的测试覆盖率对于建立对您的 Web 应用程序的信心至关重要。 Jest 是一个 JavaScript 测试运行器,提供了编写和运行测试的资源。 React Testing Library 提供了一组测试助手,根据用户交互而不是组件的实现细节来组织您的测试。 Jest 和 React Testing Library 都预装在 Create React App 中,并遵循测试应用程序应该类似于软件将如何使用的指导原则。

在本教程中,您将在包含各种 UI 元素的示例项目中测试异步代码和交互。 您将使用 Jest 编写和运行单元测试,并将 React Testing Library 实现为一个辅助 DOM(文档对象模型)库来处理与组件的交互。

先决条件

完成本教程需要以下内容:

  • Node.js 版本14或更高版本已安装在您的本地计算机上。要在macOS或Ubuntu 18.04上安装Node.js,请按照如何在macOS上安装Node.js并创建本地开发环境中的步骤,或者在使用PPA安装部分查看如何在Ubuntu 18.04上安装Node.js

  • 在您的本地计算机上安装 npm 版本为 5.2 或更高版本,您将需要在示例项目中使用 Create React Appnpx。如果您还没有安装 npm,请立即安装。对于 Linux,请使用命令 sudo apt install npm

    • 为了使本教程中的 npm 包正常工作,请安装 build-essential 包。对于 Linux,请使用命令 sudo apt install build-essential
  • 在您的本地计算机上安装 Git。您可以检查 Git 是否已安装在您的计算机上,或者按照您的操作系统进行安装过程,参考 在 Ubuntu 20.04 上安装 Git

  • 熟悉React,您可以通过如何在React.js中编码系列进行开发。因为示例项目是使用Create React App引导的,所以您无需单独安装。

  • 对于作为测试运行器或框架的有一定了解是有帮助的,但并非必需。因为Jest已经与Create React App预先打包,所以您无需单独安装。

步骤1 — 设置项目

在这一步中,您将克隆一个示例项目并启动测试套件。示例项目利用了三个主要工具:Create React App、Jest 和 React Testing Library。Create React App 用于引导单页 React 应用程序。Jest 用作测试运行器,而 React Testing Library 则提供了围绕用户交互构建测试的辅助工具。

要开始,您将从 GitHub 克隆一个预先构建的 React 应用程序。您将使用Doggy Directory应用程序,这是一个利用Dog API构建的示例项目,用于基于特定品种的狗图像集合构建搜索和显示系统。

要从 Github 克隆项目,请打开您的终端并运行以下命令:

  1. git clone https://github.com/do-community/doggy-directory

您将看到类似于此的输出:

Output
Cloning into 'doggy-directory'... remote: Enumerating objects: 64, done. remote: Counting objects: 100% (64/64), done. remote: Compressing objects: 100% (48/48), done. remote: Total 64 (delta 21), reused 55 (delta 15), pack-reused 0 Unpacking objects: 100% (64/64), 228.16 KiB | 3.51 MiB/s, done.

切换到doggy-directory文件夹:

  1. cd doggy-directory

安装项目依赖项:

  1. npm install

npm install命令将安装package.json文件中定义的所有项目依赖项。

安装完依赖项后,您可以查看应用程序的部署版本,或者您可以使用以下命令在本地运行应用程序:

  1. npm start

如果选择在本地运行应用程序,则会在http://localhost:3000/上打开。您将在终端中看到以下输出:

Output
Compiled successfully! You can now view doggy-directory in the browser. Local: http://localhost:3000 On Your Network: http://network_address:3000

启动后,应用程序的登录页面将如下所示:

项目依赖已安装,并且应用现在正在运行。接下来,打开一个新的终端并使用以下命令启动测试:

  1. npm test

使用 npm test 命令以交互式监视模式启动测试,Jest 作为测试运行程序。在监视模式下,测试会在文件更改后自动重新运行。测试会在你更改文件时运行,并告诉你该更改是否通过了测试。

第一次运行 npm test 后,你会在终端看到以下输出:

Output
No tests found related to files changed since last commit. Press `a` to run all tests, or run Jest with `--watchAll`. Watch Usage › Press a to run all tests. › Press f to run only failed tests. › Press q to quit watch mode. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. › Press Enter to trigger a test run.

现在你已经运行了示例应用程序和测试套件,你可以开始测试登陆页面。

第二步 — 测试登陆页面

默认情况下,Jest 会查找具有 .test.js 后缀和具有 .js 后缀的文件在 __tests__ 文件夹中。当你对相关的测试文件进行更改时,它们将被自动检测到。随着测试用例的修改,输出将自动更新。为 doggy-directory 示例项目准备的测试文件在添加测试范例之前设置了最小的代码。在这一步中,你将编写测试来验证应用程序的登陆页面在执行搜索之前能够加载。

在你的编辑器中打开 src/App.test.js,你会看到以下代码:

src/App.test.js
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders the landing page', () => {
  render(<App />);
});

A minimum of one test block is required in each test file. Each test block accepts two required parameters: the first argument is a string representing the name of the test case; the second argument is a function that holds the expectations of the test.

在函数内部,React Testing Library 提供了一个render方法,用于将您的组件渲染到 DOM 中。将要测试的组件渲染到测试环境的 DOM 中后,您现在可以开始编写代码来断言预期功能。

您将在render方法中添加一个测试块,以测试在进行任何 API 调用或选择之前着陆页面是否准确渲染。在render方法下方添加以下突出显示的代码:

src/App.test.js
...
test('renders the landing page', () => {
  render(<App />);
  
  expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
  expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
  expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
  expect(screen.getByRole("img")).toBeInTheDocument();
});

expect函数每次您想要验证特定结果时都会使用,它接受一个参数,表示您的代码产生的值。大多数expect函数都与一个匹配器函数配对,以断言关于特定值的某些内容。对于大多数这些断言,您将使用由jest-dom提供的额外匹配器,以便更轻松地检查在 DOM 中发现的常见方面。例如,.toHaveTextContent是第一行中expect函数的匹配器,而getByRole("heading")是用于获取 DOM 元素的选择器。

React Testing Library 提供了screen对象,作为访问断言测试 DOM 环境所需的相关查询的便捷方式。默认情况下,React Testing Library 提供了允许您在 DOM 中定位元素的查询。查询有三个主要类别:

  • getBy*(最常用)
  • queryBy*(在测试时用于检测元素不存在而不抛出错误时使用)
  • findBy*(在测试异步代码时使用)

每种查询类型都有特定的目的,稍后在教程中将对其进行定义。在这一步中,您将专注于getBy*查询,这是最常见的查询类型。要查看不同查询变体的详尽列表,您可以查阅React的查询速查表

下面是Doggy Directory登录页面的注释图像,指示第一个测试(渲染登录页面)覆盖的每个部分:

每个expect函数都是根据以下内容(如上述注释图像中所示)进行断言:

  1. 您期望具有标题角色的元素具有子字符串匹配Doggy Directory
  2. 您期望选择输入具有选择品种的确切显示值。
  3. 您期望搜索按钮处于禁用状态,因为尚未进行选择。
  4. 您期望占位图像存在于文档中,因为尚未进行搜索。

完成后,保存src/App.test.js文件。因为测试正在以监视模式运行,更改将自动注册。如果更改没有自动注册,您可能需要停止并重新启动测试套件。

现在,当您在终端中查看测试时,您将看到以下输出:

Output
PASS src/App.test.js ✓ renders the landing page (172 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 2.595 s, estimated 5 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

在此步骤中,您编写了一个初始测试,以验证Doggy Directory登录页面的初始渲染视图。在下一步中,您将学习如何模拟API调用以测试异步代码。

第3步 – 模拟fetch方法

在此步骤中,您将回顾一种模拟JavaScript的fetch方法的方法。虽然有多种方法可以实现这一点,但此实现将使用Jest的spyOnmockImplementation方法。

当您依赖外部API时,它们的API可能会崩溃或花费一段时间才能返回响应。模拟fetch方法提供了一个一致和可预测的环境,使您对测试更有信心。API模拟机制是必要的,以正确运行使用外部API的测试。

注意:为了简化这个项目,你将模拟 fetch 方法。但是,建议你在模拟异步代码时使用更健壮的解决方案,比如Mock Service Worker(MSW),适用于更大规模、适合生产环境的代码库。

打开编辑器中的 src/mocks/mockFetch.js,查看 mockFetch 方法的工作原理:

src/mocks/mockFetch.js
const breedsListResponse = {
    message: {
        boxer: [],
        cattledog: [],
        dalmatian: [],
        husky: [],
    },
};

const dogImagesResponse = {
    message: [
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_1042.jpg ",
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_5177.jpg",
    ],
};

export default async function mockFetch(url) {
    switch (url) {
        case "https://dog.ceo/api/breeds/list/all": {
            return {
                ok: true,
                status: 200,
                json: async () => breedsListResponse,
            };
        }
        case "https://dog.ceo/api/breed/husky/images" :
        case "https://dog.ceo/api/breed/cattledog/images": {
            return {
                ok: true,
                status: 200,
                json: async () => dogImagesResponse,
            };
        }
        default: {
            throw new Error(`Unhandled request: ${url}`);
        }
    }
}

mockFetch 方法返回一个对象,其结构与应用程序中 API 调用的 fetch 调用返回的结构非常相似。mockFetch 方法对测试狗狗目录应用程序中两个区域的异步功能至关重要:填充品种列表的选择下拉菜单以及执行搜索时检索狗狗图片的 API 调用。

关闭 src/mocks/mockFetch.js。现在你了解了 mockFetch 方法将如何在你的测试中使用,你可以将其导入到测试文件中。将 mockFetch 函数作为参数传递给 mockImplementation 方法,然后将其用作 fetch API 的仿真实现。

src/App.test.js 中,添加以下代码中的突出显示的行以导入 mockFetch 方法:

src/App.test.js
import { render, screen } from '@testing-library/react';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks()
});
...

此代码将设置并拆除模拟实现,以便每个测试都从一个公平的起点开始。

jest.spyOn(window, "fetch"); 创建一个模拟函数,用于跟踪附加到全局 window 变量的 DOM 中的 fetch 方法的调用。

.mockImplementation(mockFetch); 接受一个函数,该函数将被用于实现模拟方法。因为这个命令覆盖了原始的 fetch 实现,所以每当在应用代码中调用 fetch 时,它都会运行。

完成后,保存 src/App.test.js 文件。

现在,当您在终端中查看测试时,您将收到以下输出:

Output
console.error Warning: An update to App inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state */ }); /* assert on the output */ This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at App (/home/sammy/doggy-directory/src/App.js:5:31) 18 | }) 19 | .then((json) => { > 20 | setBreeds(Object.keys(json.message)); | ^ 21 | }); 22 | }, []); 23 | ... PASS src/App.test.js ✓ renders the landing page (429 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.178 s, estimated 2 s Ran all test suites related to changed files.

警告告诉您发生了状态更新,但输出还表明测试已成功模拟了 fetch 方法。

在这一步中,您模拟了 fetch 方法并将该方法合并到了一个测试套件中。虽然测试通过了,但您仍然需要处理这个警告。

第 4 步 —— 修复 act 警告

在这一步中,您将学习如何修复第 3 步更改后出现的 act 警告。

act 警告是因为您已经模拟了 fetch 方法,当组件挂载时,它会发出一个 API 调用来获取品种列表。品种列表存储在一个状态变量中,该变量填充了选择输入中的 option 元素。

下面的图片显示了在成功进行 API 调用以填充品种列表后选择输入的样子:

警告是因为状态在测试块完成渲染组件后被设置。

为了解决这个问题,在src/App.test.js中添加如下突出显示的修改:

src/App.test.js
...
test('renders the landing page', async () => {
   render(<App />);
   
   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument();
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

async关键字告诉Jest,异步代码是由组件挂载时发生的API调用导致的。

A new assertion with the findBy query verifies that the document contains an option with the value of husky. findBy queries are used when you need to test asynchronous code that is dependent on something being in the DOM after a period of time. Because the findBy query returns a promise that gets resolved when the requested element is found in the DOM, the await keyword is used within the expect method.

完成后,请保存对src/App.test.js的更改。

有了这些新添加的内容,您将会发现在测试中不再出现act警告:

Output
PASS src/App.test.js ✓ renders the landing page (123 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.942 s, estimated 2 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

在这一步骤中,您学会了如何修复在处理异步代码时可能出现的act警告。接下来,您将添加第二个测试用例来验证Doggy Directory应用程序的交互功能。

第5步 — 测试搜索功能

在最后一步,您将编写一个新的测试用例来验证搜索和图像显示功能。您将利用各种查询和API方法来实现正确的测试覆盖率。

返回编辑器中的src/App.test.js文件。在文件顶部,使用以下突出显示的命令导入user-event伴侣库和waitForElementToBeRemoved异步方法到测试文件中:

src/App.test.js
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';import userEvent from '@testing-library/user-event'; 
...

您将在本节稍后使用这些导入。

在初始test()方法之后,添加一个新的异步测试块,并使用以下代码块呈现App组件:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
})

组件呈现后,您现在可以添加验证Doggy Directory应用程序的交互功能的函数。

仍然在src/App.test.js中,在第二个test()方法中添加突出显示的代码块:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
   
   //模拟选择选项并验证其值
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");
})

上面突出显示的部分将模拟选择狗品种并验证正确的值是否显示。

getByRole查询抓取所选元素并将其分配给select变量。

类似于您如何在第4步中修复act警告,使用findByRole查询等待cattledog选项出现在文档中,然后进行进一步的断言。

前面导入的userEvent对象将模拟常见的用户交互。在这个示例中,selectOptions方法选择了你在上一行等待的cattledog选项。

最后一行断言select变量包含上面选择的cattledog值。

你将添加到Javascript test()块的下一部分将发起搜索请求,以根据所选择的品种找到狗的图像,并确认加载状态的存在。

添加以下突出显示的行:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
    
   //...模拟选择选项并验证其值

  //模拟发起搜索请求
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   //加载状态显示,并在显示结果后移除
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));
})

getByRole查询定位搜索按钮,并将其分配给searchBtn变量。

toBeDisabled jest-dom匹配器将验证在进行品种选择时搜索按钮未被禁用。

click方法在userEvent对象上模拟点击搜索按钮。

waitForElementToBeRemoved 异步帮助函数 之前导入的会在搜索 API 调用进行时等待 Loading 消息的出现和消失。 waitForElementToBeRemoved 回调内的 queryByText 检查元素是否不存在而不会抛出错误。

下面的图片显示了搜索进行中时将显示的加载状态:

接下来,添加以下 JavaScript 代码来验证图片和结果计数的显示:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />)
   
   //...模拟选择选项并验证其值
   //...模拟发起搜索请求
   //...加载状态显示并在结果显示后移除
          
   //验证图片显示和结果计数
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

getAllByRole 查询将选择所有狗的图片并将它们分配给 dogImages 变量。 查询的 *AllBy* 变体返回一个包含多个与指定角色匹配的元素的数组。 *AllBy* 变体不同于 ByRole 变体,后者只能返回单个元素。

模拟的 fetch 实现在响应中包含了两个图片 URL。使用 Jest 的 toHaveLength 匹配器,您可以验证显示了两张图片。

getByText 查询将检查右上角出现的正确结果计数。

使用 toHaveAccessibleName 匹配器的两个断言将验证与单个图像关联的适当 alt 文本。

A completed search displaying images of the dog based on the breed selected along with the number of results found will look like this:

当您将新的 JavaScript 代码的所有部分组合在一起时,App.test.js 文件将如下所示:

src/App.test.js
import {render, screen, waitForElementToBeRemoved} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks();
});

test('renders the landing page', async () => {
   render(<App />);

   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument()
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

test("should be able to search and display dog image results", async () => {
   render(<App />);

   // 模拟选择选项并验证其值
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");

   // 启动搜索请求
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   // 加载状态显示,并在显示结果后移除
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));

   // 验证图像显示和结果计数
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

保存在 src/App.test.js 中所做的更改。

当您审查您的测试时,终端中的最终输出现在将具有以下输出:

Output
PASS src/App.test.js ✓ renders the landing page (273 ms) ✓ should be able to search and display dog image results (123 ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 4.916 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

在这最后一步中,您添加了一个测试,以验证 Doggy Directory 应用程序的搜索、加载和显示功能。随着最终断言的编写,您现在知道您的应用程序可以正常工作。

结论

在本教程中,您使用 Jest、React 测试库和 jest-dom 匹配器编写了测试用例。逐步构建,您根据用户与 UI 的交互方式编写了测试。您还学习了 getBy*、findBy* 和 queryBy* 查询之间的区别,以及如何测试异步代码。

要了解上述提到的主题的更多信息,请查看JestReact Testing Libraryjest-dom的官方文档。您还可以阅读 Kent C. Dodd 的使用 React Testing Library 时的常见错误,以了解在使用 React Testing Library 时的最佳实践。有关在 React 应用程序中使用快照测试的更多信息,请查看如何编写快照测试

Source:
https://www.digitalocean.com/community/tutorials/how-to-test-a-react-app-with-jest-and-react-testing-library