如何使用Jest和React Testing Library测试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(Document Object Model)庫來處理與組件的交互作用。

先決條件

完成此教程,您需要:

  • Node.js版本14或更高版本已安裝在您的本地計算機上。要在macOS或Ubuntu 18.04上安裝Node.js,請按照在macOS上安裝Node.js並創建本地開發環境中的步驟或使用PPA進行安裝的部分進行操作如何在Ubuntu 18.04上安裝Node.js

  • 在您的本地计算机上安装 npm 版本 5.2 或更高版本,您需要在示例项目中使用 Create React Appnpx。如果您没有同时安装 npmNode.js,请立即执行此操作。对于 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有一些熟悉会有所帮助,但不是必需的。因为Jest已经预先打包在Create React App中,您无需单独安装它。

步骤1 — 设置项目

在這個步驟中,您將克隆一個示例項目並啟動測試套件。示例項目使用了三個主要工具:Create React App、Jest 和 React Testing Library。Create React App 用於啟動單頁 React 應用程序。Jest 用作測試運行器,而 React Testing Library 提供了用於結構化測試的輔助工具,圍繞用戶交互展開。

首先,您將從 GitHub 克隆一個預建的 React App。您將與名為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

使用Jest作為測試運行器,npm test命令以交互式觀察模式啟動測試。在觀察模式下,文件更改後測試會自動重新運行。測試將在您更改文件時運行並通知您該更改是否通過測試。

首次運行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.

現在您已經運行了示例應用程序和測試套件,您可以開始測試登陸頁面。

步驟2 — 測試登陸頁面

默認情況下,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. 您期望具有heading role的元素具有子字符串匹配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方法。然而,建議您在模擬大型、可生產的代碼庫中的異步代碼時使用更健壯的解決方案,例如模擬服務器工作者(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調用的結構非常相似。在Doggy Directory應用程序中測試異步功能的兩個區域都需要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");創建一個模擬函數,將跟踪附加到DOM全局window變量的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 匹配器的兩個斷言將驗證個別圖像是否關聯有適當的替代文字。

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 Testing Library 和 jest-dom 匹配器编写了测试用例。逐步构建,您根据用户与 UI 的交互编写了测试。您还学习了 getBy*findBy*queryBy* 查询之间的区别,以及如何测试异步代码。

要了解更多有关上述主题的信息,请查看 JestReact Testing Libraryjest-dom 官方文档。您还可以阅读 Kent C. Dodd 的 Common Mistakes with React Testing Library,了解在使用 React Testing Library 时的最佳实践。有关在 React 应用程序中使用快照测试的更多信息,请参阅 How To Write Snapshot Tests

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