目前前端开发中,基本都是组件化开发。好的组件可以极大地提高代码的复用性。一些功能相对稳定的组件或者hooks,为了保证组件修改维护后的功能正常,这时候就需要单元测试。有了单元测试之后,组件代码修改之后只需要跑一遍单元测试就知道功能是否正常,可以提高效率,减少出错概率。
那么,如何写React的组件或hooks的单测呢?
目前常用的是用jest测试框架和React Testing Library (RTL) 结合来写React的组件、hooks的单测。
jest测试框架的基本使用可以参照笔者的另一篇文章:juejin.cn/post/745746...
下面一起来实践一下
搭建项目
从零开始,新建一个文件夹并执行npm init
初始化项目
安装相关依赖
主要安装以下依赖
React 相关依赖
-
react
-
react-dom
javascript
npm install react react-dom
- @types/react
- @types/react-dom
javascript
npm install @types/react @types/react-dom -D
jest和babel相关依赖
- jest
- babel-jest
- @types/jest
- identity-obj-proxy (方便地模拟 CSS 模块导入)
- jest-environment-jsdom (jsdom 模拟浏览器环境)
- @babel/core
- @babel/preset-env
- @babel/preset-react
- @babel/preset-typescript
javascript
npm install jest babel-jest @types/jest identity-obj-proxy jest-environment-jsdom @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript -D
React Testing Library 相关依赖
- @testing-library/jest-dom
- @testing-library/react
- @testing-library/user-event
javascript
npm install @testing-library/jest-dom @testing-library/react @testing-library/user-event -D
typescript 依赖
- typescript
javascript
npm install typescript -D
添加相关配置文件
添加babel配置文件
添加babel.config.json
json
{
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}
添加jest配置文件
添加jest.setup.js
javascript
import "@testing-library/jest-dom";
添加jest.config.js
javascript
module.exports = {
testEnvironment: "jsdom", // 使用 jsdom 模拟浏览器环境
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"], // 配置 Jest 环境
transform: {
"^.+\\.[t|j]sx?$": "babel-jest", // 使用 babel-jest 来转换文件
},
moduleNameMapper: {
"\\.(css|less|sass|scss)$": "identity-obj-proxy", // Mock 样式文件
},
};
组件简单测试
先写一个简单的button组件
tsx
import React from "react";
function Button() {
return <div className="my-button">button</div>;
}
export default Button;
再写对应的单测文件
javascript
import React from "react";
import { render, screen } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";
test("renders button", () => {
render(<Button />);
const button = screen.getByText(/button/i);
expect(button).toBeInTheDocument();
});
单测文件的流程大概是
1、通过@testing-library/react
库中的render
方法渲染<Button />
组件
2、通过@testing-library/react
库中的screen
来查询 dom,查找特定的文本节点
3、然后通过jest断言它在document中
然后在package.json
文件中添加
json
"scripts": {
"test": "jest"
},
跑下npm run test
即可看到对应的结果
可以看到,测试是通过的
上面的测试文件也可以写成这样
javascript
import React from "react";
import { render } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";
test("renders button", () => {
const { getByText } = render(<Button />);
const button = getByText(/button/i);
expect(button).toBeInTheDocument();
});
或者是这样
javascript
import React from "react";
import { render } from "@testing-library/react";
import Button from "./index.tsx";
import "@testing-library/jest-dom";
test("renders button", () => {
const { container } = render(<Button />);
const button = container.querySelector(".my-button");
expect(button).toBeInTheDocument();
expect(button.textContent).toMatch(/button/i);
});
可见,render函数返回了一个对象,该对象包含一些属性和方法,其中还包含组件挂载的容器dom,它是一个HTMLElement对象,上面有各种我们熟悉的dom方法,例如上面例子的querySelector
方法。
查询
上面例子中用了getByText
的查询方式,它会搜索有文本节点并且textContent
属性和查询参数匹配的元素。
除了这个查询方法外,还有很多其他的查询方法。
查询类型可以分成三种类型:"get"、"find"、"query",三种类型的查询方式不同
-
getBy...:返回查询的匹配节点,如果没有元素匹配或者 找到多个匹配项,则抛出描述性错误(如果需要查找多个元素,需要使用
getAllBy...
)javascriptimport React from "react"; import { render, screen } from "@testing-library/react"; import Button from "./index.tsx"; import "@testing-library/jest-dom"; test("renders button", () => { render(<Button />); const table = screen.getByText(/table/i); expect(table).not.toBeInTheDocument(); }); // 会直接报错 // TestingLibraryElementError: Unable to find an element with the text: /table/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
-
findBy...:返回一个 Promise,当找到与给定查询匹配的元素时,该 Promise 将被解析。如果未找到任何元素,或者在默认超时 1000 毫秒后找到多个元素(如果需要查找多个元素,需要使用
findAllBy...
),则该 Promise 将被拒绝。javascriptimport React from "react"; import { render, screen } from "@testing-library/react"; import Button from "./index.tsx"; import "@testing-library/jest-dom"; test("renders button", () => { render(<Button />); const button = screen.findByText(/button/i); expect(button).resolves.toBeInTheDocument(); });
-
queryBy...:返回查询的匹配节点,如果没有元素匹配则返回
null
。这对于断言不存在的元素很有用。如果找到多个匹配项,则抛出错误(如果需要查找多个元素,则使用queryAllBy...
。)javascriptimport React from "react"; import { render, screen } from "@testing-library/react"; import Button from "./index.tsx"; import "@testing-library/jest-dom"; test("renders button", () => { render(<Button />); const table = screen.queryByText(/table/i); expect(table).toBeNull(); });
除了上面的这种查询包含特定文本内容外,还有很多其他的查询方式,包括查单个和多个
ByText
查询包含特定文本内容的任意元素。
- getByText
- findByText
- queryByText
- getAllByText
- findAllByText
- queryAllByText
ByRole
查询具有给定角色的元素(默认角色会被考虑在内)
- getByRole
- findByRole
- queryByRole
- getAllByRole
- queryAllByRole
- findAllByRole
tsx
// 组件
import React from "react";
function Page() {
return (
<div>
<nav role="navigation"></nav>
<button></button> {/* button有默认button角色 */}
</div>
);
}
export default Page;
tsx
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("byRole", () => {
render(<Page />);
const navigation = screen.getByRole("navigation");
expect(navigation).toBeInTheDocument();
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
ByLabelText
查询与给定匹配的"label",主要用于查找与指定标签文本关联的表单控件,例如 <input>
, <textarea>
, <select>
等。通过 <label>
元素的 for
属性或嵌套关系来查找对应的表单控件。
- getByLabelText
- findByLabelText
- queryByLabelText
- getAllByLabelText
- findAllByLabelText
- queryAllByLabelText
tsx
// 组件
import React from "react";
function Page() {
return (
<div>
<label htmlFor="username-input">label-one</label>
<input id="username-input" />
<label id="username-label">label-two</label>
<input aria-labelledby="username-label" />
<label>
label-three <input />
</label>
<label>
<span>label-four</span>
<input />
</label>
<input aria-label="label-five" />
</div>
);
}
export default Page;
tsx
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("byLabelText", () => {
render(<Page />);
const label1 = screen.getByLabelText("label-one");
expect(label1).toBeInTheDocument();
const label2 = screen.getByLabelText("label-two");
expect(label2).toBeInTheDocument();
const label3 = screen.getByLabelText("label-three");
expect(label3).toBeInTheDocument();
const label4 = screen.getByLabelText("label-four");
expect(label4).toBeInTheDocument();
const label5 = screen.getByLabelText("label-five");
expect(label5).toBeInTheDocument();
});
ByPlaceholderText
查询占位符属性匹配的元素
- getByPlaceholderText
- findByPlaceholderText
- queryByPlaceholderText
- getAllByPlaceholderText
- findAllByPlaceholderText
- queryAllByPlaceholderText
tsx
// 组件
import React from "react";
function Page() {
return (
<div>
<input placeholder="name" />
</div>
);
}
export default Page;
tsx
// test文件
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByPlaceholderText", () => {
render(<Page />);
const input = screen.getByPlaceholderText("name");
expect(input).toBeInTheDocument();
});
ByDisplayValue
查询具有匹配显示值的input
、textarea
、select
或其他元素。
- getByDisplayValue
- findByDisplayValue
- queryByDisplayValue
- getAllByDisplayValue
- findAllByDisplayValue
- queryAllByDisplayValue
tsx
import React, { useEffect } from "react";
function Page() {
useEffect(() => {
const myTextArea = document.getElementById(
"myTextArea"
) as HTMLInputElement;
myTextArea.value = "Hello World";
}, []);
return (
<div>
<input type="text" defaultValue="jack" />
<textarea id="myTextArea" />
</div>
);
}
export default Page;
tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByDisplayValue", () => {
render(<Page />);
const input = screen.getByDisplayValue("jack");
const textArea = screen.queryByDisplayValue("Hello World");
expect(input).toBeInTheDocument();
expect(textArea).toBeInTheDocument();
});
如果是select
元素的话,会查询和<select>
选定的<option>
相匹配的
tsx
import React from "react";
function Page() {
return (
<div>
<select defaultValue="three">
<option value="one">One</option>
<option value="two">Two</option>
<option value="three">Three</option>
</select>
</div>
);
}
export default Page;
tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByDisplayValue", () => {
render(<Page />);
const select1 = screen.getByDisplayValue("Three");
const select2 = screen.queryByDisplayValue("One");
expect(select1).toBeInTheDocument();
expect(select2).toBeNull();
});
ByAltText
查询和alt属性相匹配的元素(一般是<img>
)
- getByAltText
- findByAltText
- queryByAltText
- getAllByAltText
- findAllByAltText
- queryAllByAltText
tsx
import React from "react";
function Page() {
return <img src="../default.png" alt="my-img" />;
}
export default Page;
tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByAltText", () => {
render(<Page />);
const img = screen.getByAltText("my-img");
expect(img).toBeInTheDocument();
});
ByTitle
查询和title属性相匹配的元素
- getByTitle
- findByTitle
- queryByTitle
- getAllByTitle
- findAllByTitle
- queryAllByTitle
tsx
import React from "react";
function Page() {
return (
<div>
<span title="Open"></span>
<svg>
<title>Close</title>
<g>
<path />
</g>
</svg>
</div>
);
}
export default Page;
tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByTitle", () => {
render(<Page />);
const openElement = screen.getByTitle("Open");
const closeElement = screen.getByTitle("Close");
expect(openElement).toBeInTheDocument();
expect(closeElement).toBeInTheDocument();
});
ByTestId
查询和data-testid
匹配的元素,可以当成是"container.querySelector([data-testid="${yourId}"]
)"的快捷查询方式
- getByTestId
- findByTestId
- queryByTestId
- getAllByTestId
- findAllByTestId
- queryAllByTestId
tsx
import React from "react";
function Page() {
return <div data-testid="my-data-testid" />;
}
export default Page;
tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("ByTestId", () => {
render(<Page />);
const element = screen.getByTestId("my-data-testid");
expect(element).toBeInTheDocument();
});
用户操作
事件
像click
、change
这些事件要怎么测试呢,testing-library
提供了一个fireEvent
方法。
看下下面的例子,一个简单的点击数字递增的组件
tsx
import React, { useState } from "react";
function Page() {
const [num, setNum] = useState(0);
const add = () => {
setNum((val) => ++val);
};
return (
<div>
<p className="num-val">{num}</p>
<button onClick={add}>add</button>
</div>
);
}
export default Page;
tsx
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("click", () => {
const { container } = render(<Page />);
const numVal = container.querySelector(".num-val");
expect(numVal.textContent).toBe("0");
const button = screen.getByText("add");
fireEvent.click(button);
expect(numVal.textContent).toBe("1");
});
上面的test文件的流程大概如下
1、渲染组件
2、用 container 节点的 dom api 根据classname查询到 显示数字内容的 P 标签
3、断言该标签的文本节点是 0
4、查询拿到 button 标签
5、用fireEvent.click模拟触发 button 的点击事件
6、断言 P 标签的文本节点是 1
运行npm run test
后可以发现测试通过了。
fireEvent 可以触发任何元素的任何事件。其用法为fireEvent[eventName] (node: HTMLElement, eventProperties: Object)
eventProperties上有许多属性,比较常用的有target
属性,当提供target
属性时,这些属性将分配给接收事件的节点。这对于change
事件特别有用。
tsx
import React from "react";
function Page() {
return (
<label>
date-input
<input type="date" />
</label>
);
}
export default Page;
tsx
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("change", () => {
render(<Page />);
const dateInput = screen.getByLabelText("date-input");
expect(dateInput.value).toBe("");
fireEvent.change(dateInput, { target: { value: "2020-05-20" }});
expect(dateInput.value).toBe("2020-05-20");
});
还有dataTransfer
属性,用于拖动事件
tsx
fireEvent.drop(getByLabelText(/drop files/i), {
dataTransfer: {
files: [new File(['xxx'], 'default.png', {type: 'image/png'})],
},
})
键盘事件相关属性,keyPress
、keyDown
和keyUp
等键盘事件在触发时,需要引用 DOM 中的元素和要触发的键。
javascript
fireEvent.keyDown(domNode, {key: 'Enter', code: 'Enter', charCode: 13})
fireEvent.keyDown(domNode, {key: 'A', code: 'KeyA'})
user-event
fireEvent
允许开发人员触发任何元素上的任何事件,但是并不是事件的整个完整模拟。例如点击事件,fireEvent.click
会创建一个点击事件并在给定的 DOM 节点上调度该事件。但是实际用户点击某个元素时,会触发鼠标移动、聚焦、点击等一系列事件。所以当我们需要在真实环境中测试组件时,我们就需要用到@testing-library/user-event
。
例如下面的例子,按钮在聚焦时会触发事件从而改变文本内容。
tsx
import React, { useState } from "react";
function Page() {
const [isFocus, setIsFocus] = useState(false);
function buttonFocus() {
setIsFocus(true);
}
return (
<div>
<p className="my-text">{isFocus ? "focus" : "out of focus"}</p>
<button onFocus={buttonFocus} className="my-button"></button>
</div>
);
}
export default Page;
如果我们想要在测试代码中测试点击按钮来触发按钮的聚焦事件,按照下面的测试文件,测试结果会报错。
tsx
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("event", async () => {
const { container } = render(<Page />);
const button = container.querySelector(".my-button");
fireEvent.click(button);
const text = container.querySelector(".my-text");
expect(text.textContent).toBe("focus");
});
我们需要借助@testing-library/user-event
来模拟真实点击。
tsx
import React from "react";
import { render } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
test("event", async () => {
const user = userEvent.setup();
const { container } = render(<Page />);
const button = container.querySelector(".my-button");
await user.click(button);
const text = container.querySelector(".my-text");
expect(text.textContent).toBe("focus");
});
运行代码,可以发现,测试通过了。
异步
waitFor
当需要等待任意一段时间时,可以使用waitFor
。
例如改动一下上面点击数字递增的例子。
tsx
import React, { useState } from "react";
function Page() {
const [num, setNum] = useState(0);
const add = () => {
setTimeout(() => {
setNum((val) => ++val);
}, 2000); // 增加定时器
};
return (
<div>
<p className="num-val">{num}</p>
<button onClick={add}>add</button>
</div>
);
}
export default Page;
修改完成后,如果还是按照之前的测试代码运行则会报错。我们需要在测试代码中增加等待,这时就需要用到waitFor
。
修改测试代码如下
tsx
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import Page from "./index.tsx";
import "@testing-library/jest-dom";
test("click", async () => {
const { container } = render(<Page />);
const numVal = container.querySelector(".num-val");
expect(numVal.textContent).toBe("0");
const button = screen.getByText("add");
fireEvent.click(button);
await waitFor(
() => {
expect(numVal.textContent).toBe("1");
},
{ timeout: 3000 }
);
});
运行后可以发现测试通过了
其实前面查询里的
findBy...
查询是getBy...
和waitFor
的结合,它接收waitFor
选项作为最后一个参数,即await screen.findByText('text', queryOptions, waitForOptions)
hooks测试
hooks测试需要借助renderHook
Api
假如有下面一个简单的hooks。
typescript
import { useState } from "react";
export default function useAdd(initNum = 0) {
const [num, setNum] = useState(0);
const add = () => {
setNum((num) => ++num);
};
return [num, add];
}
这个hooks的单测可以是:
typescript
import { renderHook, act } from "@testing-library/react";
import useAdd from "./index.ts";
test("hook test", async () => {
const hook = renderHook(() => useAdd(11));
const [num, add] = hook.result.current;
act(() => {
add();
});
expect(hook.result.current[0]).toBe(12);
hook.unmount();
});