React之集成测试 –测试技巧

React 组件的常见测试模式。

注意:

此章节假设你正在使用 Jest 作为测试运行器。如果你使用不同的测试运行器,你可能需要调整 API,但整体的解决方案是相同的。在测试环境章节阅读更多关于设置测试环境的细节。

在本章中,我们将主要使用函数组件。然而,这些测试策略并不依赖于实现细节,它对于 class 组件也同样有效。


创建/清理

对于每个测试,我们通常希望将 React 树渲染给附加到 document的 DOM 元素。这点很重要,以便它可以接收 DOM 事件。当测试结束时,我们需要“清理”并从 document 中卸载树。

常见的方法是使用一对 beforeEachafterEach 块,以便它们一直运行,并隔离测试本身造成的影响:

1import { unmountComponentAtNode } from "react-dom"; 2 3let container = null; 4beforeEach(() => { 5 // 创建一个 DOM 元素作为渲染目标 6 container = document.createElement("div"); 7 document.body.appendChild(container); 8}); 9 10afterEach(() => { 11 // 退出时进行清理 12 unmountComponentAtNode(container); 13 container.remove(); 14 container = null; 15});

你可以使用不同的测试模式,但请注意,即使测试失败,也需要执行清理。否则,测试可能会导致“泄漏”,并且一个测试可能会影响另一个测试的行为。这使得其难以调试。


act()

在编写 UI 测试时,可以将渲染、用户事件或数据获取等任务视为与用户界面交互的“单元”。react-dom/test-utils 提供了一个名为 act() 的 helper,它确保在进行任何断言之前,与这些“单元”相关的所有更新都已处理并应用于 DOM:

1act(() => { 2 // 渲染组件 3}); 4// 进行断言

这有助于使测试运行更接近真实用户在使用应用程序时的体验。这些示例的其余部分使用 act() 来作出这些保证。

你可能会发现直接使用 act() 有点过于冗长。为了避免一些样板代码,你可以使用 React 测试库,这些 helper 是使用 act() 函数进行封装的。

注意:

act 名称来自 Arrange-Act-Assert 模式。


渲染

通常,你可能希望测试组件对于给定的 prop 渲染是否正确。此时应考虑实现基于 prop 渲染消息的简单组件:

1// hello.js 2 3import React from "react"; 4 5export default function Hello(props) { 6 if (props.name) { 7 return <h1>你好,{props.name}</h1>; 8 } else { 9 return <span>嘿,陌生人</span>; 10 } 11}

我们可以为这个组件编写测试:

1// hello.test.js 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6 7import Hello from "./hello"; 8 9let container = null; 10beforeEach(() => { 11 // 创建一个 DOM 元素作为渲染目标 12 container = document.createElement("div"); 13 document.body.appendChild(container); 14}); 15 16afterEach(() => { 17 // 退出时进行清理 18 unmountComponentAtNode(container); 19 container.remove(); 20 container = null; 21}); 22 23it("渲染有或无名称", () => { 24 act(() => { render(<Hello />, container); }); expect(container.textContent).toBe("嘿,陌生人"); 25 act(() => { 26 render(<Hello name="Jenny" />, container); 27 }); 28 expect(container.textContent).toBe("你好,Jenny!"); 29 30 act(() => { 31 render(<Hello name="Margaret" />, container); 32 }); 33 expect(container.textContent).toBe("你好,Margaret!"); 34});

数据获取

你可以使用假数据来 mock 请求,而不是在所有测试中调用真正的 API。使用“假”数据 mock 数据获取可以防止由于后端不可用而导致的测试不稳定,并使它们运行得更快。注意:你可能仍然希望使用一个“端到端”的框架来运行测试子集,该框架可显示整个应用程序是否一起工作。

1// user.js 2 3import React, { useState, useEffect } from "react"; 4 5export default function User(props) { 6 const [user, setUser] = useState(null); 7 8 async function fetchUserData(id) { 9 const response = await fetch("/" + id); 10 setUser(await response.json()); 11 } 12 13 useEffect(() => { 14 fetchUserData(props.id); 15 }, [props.id]); 16 17 if (!user) { 18 return "加载中..."; 19 } 20 21 return ( 22 <details> <summary>{user.name}</summary> <strong>{user.age}</strong><br /> 住在 {user.address} </details> 23 ); 24}

我们可以为它编写测试:

1// user.test.js 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6import User from "./user"; 7 8let container = null; 9beforeEach(() => { 10 // 创建一个 DOM 元素作为渲染目标 11 container = document.createElement("div"); 12 document.body.appendChild(container); 13}); 14 15afterEach(() => { 16 // 退出时进行清理 17 unmountComponentAtNode(container); 18 container.remove(); 19 container = null; 20}); 21 22it("渲染用户数据", async () => { 23 const fakeUser = { name: "Joni Baez", age: "32", address: "123, Charming Avenue" }; jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ json: () => Promise.resolve(fakeUser) }) ); 24 // 使用异步的 act 应用执行成功的 promise 25 await act(async () => { 26 render(<User id="123" />, container); 27 }); 28 29 expect(container.querySelector("summary").textContent).toBe(fakeUser.name); 30 expect(container.querySelector("strong").textContent).toBe(fakeUser.age); 31 expect(container.textContent).toContain(fakeUser.address); 32 33 // 清理 mock 以确保测试完全隔离 global.fetch.mockRestore();});

mock 模块

有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要。使用虚拟数据来 mock 这些模块可以使你为代码编写测试变得更容易。

考虑一个嵌入第三方 GoogleMap 组件的 Contact 组件:

1// map.js 2 3import React from "react"; 4 5import { LoadScript, GoogleMap } from "react-google-maps"; 6export default function Map(props) { 7 return ( 8 <LoadScript id="script-loader" googleMapsApiKey="YOUR_API_KEY"> <GoogleMap id="example-map" center={props.center} /> </LoadScript> 9 ); 10} 11 12// contact.js 13 14import React from "react"; 15import Map from "./map"; 16 17export default function Contact(props) { 18 return ( 19 <div> <address> 联系 {props.name},通过{" "} <a data-testid="email" href={"mailto:" + props.email}> email </a> 或者他们的 <a data-testid="site" href={props.site}> 网站 </a></address> <Map center={props.center} /> </div> 20 ); 21}

如果不想在测试中加载这个组件,我们可以将依赖 mock 到一个虚拟组件,然后运行我们的测试:

1// contact.test.js 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6 7import Contact from "./contact"; 8import MockedMap from "./map"; 9 10jest.mock("./map", () => { return function DummyMap(props) { return ( <div data-testid="map"> {props.center.lat}:{props.center.long} </div> ); };}); 11let container = null; 12beforeEach(() => { 13 // 创建一个 DOM 元素作为渲染目标 14 container = document.createElement("div"); 15 document.body.appendChild(container); 16}); 17 18afterEach(() => { 19 // 退出时进行清理 20 unmountComponentAtNode(container); 21 container.remove(); 22 container = null; 23}); 24 25it("应渲染联系信息", () => { 26 const center = { lat: 0, long: 0 }; 27 act(() => { 28 render( 29 <Contact 30 name="Joni Baez" 31 email="test@example.com" 32 site="http://test.com" 33 center={center} 34 />, 35 container 36 ); 37 }); 38 39 expect( 40 container.querySelector("[data-testid='email']").getAttribute("href") 41 ).toEqual("mailto:test@example.com"); 42 43 expect( 44 container.querySelector('[data-testid="site"]').getAttribute("href") 45 ).toEqual("http://test.com"); 46 47 expect(container.querySelector('[data-testid="map"]').textContent).toEqual( 48 "0:0" 49 ); 50});

Events

我们建议在 DOM 元素上触发真正的 DOM 事件,然后对结果进行断言。考虑一个 Toggle 组件:

1// toggle.js 2 3import React, { useState } from "react"; 4 5export default function Toggle(props) { 6 const [state, setState] = useState(false); 7 return ( 8 <button 9 onClick={() => { 10 setState(previousState => !previousState); 11 props.onChange(!state); 12 }} 13 data-testid="toggle" 14 > {state === true ? "Turn off" : "Turn on"} </button> 15 ); 16}

我们可以为它编写测试:

1// toggle.test.js 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6 7import Toggle from "./toggle"; 8 9let container = null; 10beforeEach(() => { 11 // 创建一个 DOM 元素作为渲染目标 12 container = document.createElement("div"); 13 document.body.appendChild(container);}); 14afterEach(() => { 15 // 退出时进行清理 16 unmountComponentAtNode(container); 17 container.remove(); 18 container = null; 19}); 20 21it("点击时更新值", () => { 22 const onChange = jest.fn(); 23 act(() => { 24 render(<Toggle onChange={onChange} />, container); 25 }); 26 27 // 获取按钮元素,并触发点击事件 28 const button = document.querySelector("[data-testid=toggle]"); 29 expect(button.innerHTML).toBe("Turn on"); 30 31 act(() => { 32 button.dispatchEvent(new MouseEvent("click", { bubbles: true })); 33 }); 34 expect(onChange).toHaveBeenCalledTimes(1); 35 expect(button.innerHTML).toBe("Turn off"); 36 37 act(() => { 38 for (let i = 0; i < 5; i++) { 39 button.dispatchEvent(new MouseEvent("click", { bubbles: true })); 40 } }); 41 42 expect(onChange).toHaveBeenCalledTimes(6); 43 expect(button.innerHTML).toBe("Turn on"); 44});

MDN描述了不同的 DOM 事件及其属性。注意,你需要在创建的每个事件中传递 { bubbles: true } 才能到达 React 监听器,因为 React 会自动将事件委托给 root。

注意:

React 测试库为触发事件提供了一个更简洁 helper


计时器

你的代码可能会使用基于计时器的函数(如 setTimeout)来安排将来更多的工作。在这个例子中,多项选择面板等待选择并前进,如果在 5 秒内没有做出选择,则超时:

1// card.js 2 3import React, { useEffect } from "react"; 4 5export default function Card(props) { 6 useEffect(() => { 7 const timeoutID = setTimeout(() => { 8 props.onSelect(null); 9 }, 5000); 10 return () => { 11 clearTimeout(timeoutID); 12 }; 13 }, [props.onSelect]); 14 15 return [1, 2, 3, 4].map(choice => ( 16 <button 17 key={choice} 18 data-testid={choice} 19 onClick={() => props.onSelect(choice)} 20 > {choice} </button> 21 )); 22}

我们可以利用 Jest 的计时器 mock 为这个组件编写测试,并测试它可能处于的不同状态。

1// card.test.js 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6 7import Card from "./card"; 8jest.useFakeTimers(); 9 10let container = null; 11beforeEach(() => { 12 // 创建一个 DOM 元素作为渲染目标 13 container = document.createElement("div"); 14 document.body.appendChild(container); 15}); 16 17afterEach(() => { 18 // 退出时进行清理 19 unmountComponentAtNode(container); 20 container.remove(); 21 container = null; 22}); 23 24it("超时后应选择 null", () => { 25 const onSelect = jest.fn(); 26 act(() => { 27 render(<Card onSelect={onSelect} />, container); 28 }); 29 30 // 提前 100 毫秒执行 act(() => { 31 jest.advanceTimersByTime(100); 32 }); 33 expect(onSelect).not.toHaveBeenCalled(); 34 35 // 然后提前 5 秒执行 act(() => { 36 jest.advanceTimersByTime(5000); 37 }); 38 expect(onSelect).toHaveBeenCalledWith(null); 39}); 40 41it("移除时应进行清理", () => { 42 const onSelect = jest.fn(); 43 act(() => { 44 render(<Card onSelect={onSelect} />, container); 45 }); 46 act(() => { 47 jest.advanceTimersByTime(100); 48 }); 49 expect(onSelect).not.toHaveBeenCalled(); 50 51 // 卸载应用程序 52 act(() => { 53 render(null, container); 54 }); 55 act(() => { 56 jest.advanceTimersByTime(5000); 57 }); 58 expect(onSelect).not.toHaveBeenCalled(); 59}); 60 61it("应接受选择", () => { 62 const onSelect = jest.fn(); 63 act(() => { 64 render(<Card onSelect={onSelect} />, container); 65 }); 66 67 act(() => { 68 container 69 .querySelector("[data-testid='2']") 70 .dispatchEvent(new MouseEvent("click", { bubbles: true })); 71 }); 72 73 expect(onSelect).toHaveBeenCalledWith(2); 74});

你只能在某些测试中使用假计时器。在上面,我们通过调用 jest.useFakeTimers() 来启用它们。它们提供的主要优势是,你的测试实际上不需要等待 5 秒来执行,而且你也不需要为了测试而使组件代码更加复杂。


快照测试

像 Jest 这样的框架还允许你使用 toMatchSnapshot / toMatchInlineSnapshot 保存数据的“快照”。有了这些,我们可以“保存”渲染的组件输出,并确保对它的更新作为对快照的更新显式提交。

在这个示例中,我们渲染一个组件并使用 pretty 包对渲染的 HTML 进行格式化,然后将其保存为内联快照:

1// hello.test.js, again 2 3import React from "react"; 4import { render, unmountComponentAtNode } from "react-dom"; 5import { act } from "react-dom/test-utils"; 6import pretty from "pretty"; 7 8import Hello from "./hello"; 9 10let container = null; 11beforeEach(() => { 12 // 创建一个 DOM 元素作为渲染目标 13 container = document.createElement("div"); 14 document.body.appendChild(container); 15}); 16 17afterEach(() => { 18 // 退出时进行清理 19 unmountComponentAtNode(container); 20 container.remove(); 21 container = null; 22}); 23 24it("应渲染问候语", () => { 25 act(() => { 26 render(<Hello />, container); 27 }); 28 29 expect( pretty(container.innerHTML) ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */ 30 act(() => { 31 render(<Hello name="Jenny" />, container); 32 }); 33 34 expect( 35 pretty(container.innerHTML) 36 ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */ 37 38 act(() => { 39 render(<Hello name="Margaret" />, container); 40 }); 41 42 expect( 43 pretty(container.innerHTML) 44 ).toMatchInlineSnapshot(); /* ... 由 jest 自动填充 ... */ 45});

通常,进行具体的断言比使用快照更好。这类测试包括实现细节,因此很容易中断,并且团队可能对快照中断不敏感。选择性地 mock 一些子组件可以帮助减小快照的大小,并使它们在代码评审中保持可读性。


多渲染器

在极少数情况下,你可能正在使用多个渲染器的组件上运行测试。例如,你可能正在使用 react-test-renderer 组件上运行快照测试,该组件内部使用子组件内部的 ReactDOM.render 渲染一些内容。在这个场景中,你可以使用与它们的渲染器相对应的 act() 来包装更新。

1import { act as domAct } from "react-dom/test-utils"; 2import { act as testAct, create } from "react-test-renderer"; 3// ... 4let root; 5domAct(() => { 6 testAct(() => { 7 root = create(<App />); 8 }); 9}); 10expect(root).toMatchSnapshot();

缺少什么?

如果有一些常见场景没有覆盖,请在文档网站的 issue 跟踪器上告诉我们。

本文转自 https://react.docschina.org/docs/testing-recipes.html,如有侵权,请联系删除。

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

MySQL部分从库上面因为大量的临时表tmp_table造成慢查询

背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

手写Java HashMap源码

HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22

2020年前端实用代码段,为你的工作保驾护航

有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )