本文搭建 react + antd 技术栈的测试框架,其中有一些坑已经踩过了。搭建完成之后,对实际项目中的一个 Modal 组件进行单元测试。并罗列测试过程中出现的问题及解决方案。
1. 环境搭建
搭建这样的环境,我们首先需要在工程目录中对 jest 进行配置。
- 假设 React app 是通过 create-react-app 构建的,那么我们首先将工作区清空,然后执行 yarn eject 命令,执行完成之后,可以看到 package.json 中增加了 jest 相关配置,以及根目录下新增的名为 config 的目录。
- 来到 package.json 找打配置项
jest.setupFilesAfterEnv
, 此时其值为[]
, 将其改成"<rootDir>/src/__test__/setup.js"
. - 在 src 目录下面创建名为
__test__
的子目录,然后在这个子目录中创建名为setup.js
的文件,使之符合第 2 步的配置。 - 在
setup.js
中写入下面的内容:
js
import "@testing-library/jest-dom";
console.log('Test setup.js is running!');
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
});
文件中的第一句让我们使用 expect().toBeInTheDocument()
的这类断言能够成立;第二句则是确保我们的 setup.js
文件被执行了。剩下的内容是对 window.matchMedia
全局变量的模拟,如果要测试 ant design 这个库,则必须对此对象进行模拟,否则会出错(这是个大坑)
jest 官方推荐的解决方案是:https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
但是好像是无效的!
接下来,我们只需要执行 yarn test
就可以了。我已经尝试过了,这样的设置对于常用的 antd 中的组件都是能够正常渲染的,见仓库:https://github.com/fszhangYi/basicTestingFramework
.
2. 测试练习
本节中,我们对一个真实的组件进行测试,这个组件渲染了 antd 提供的 Modal 组件。
2.1 待测组件
让我们先看一看待测组件 SunModal.js
全部代码:
jsx
import React, { useEffect, useState } from 'react';
import { Modal, Button, Input } from 'antd';
import { message } from 'antd';
import classNames from 'classnames';
const SunModal = props => {
const {
open = false,
onClose = () => '',
dataSource = {},
changeParentMode = () => '',
confirmCb = () => '',
rowData = {},
} = props;
const { comment, commentAdminRep, hideReason } = rowData;
const { title, hint, required, placeholder, mode } = dataSource;
const [visible, setVisible] = useState(open);
const [innerMode, setInnerMode] = useState(mode);
const [innerValue, setInnerValue] = useState(() => {
let rst;
switch (mode) {
case '1':
rst = undefined;
case '2':
rst = undefined;
case '3':
rst = commentAdminRep ?? undefined;
default:
rst = undefined;
}
return rst;
});
useEffect(() => {
setVisible(open);
}, [open]);
useEffect(() => {
setInnerMode(mode);
}, [mode]);
useEffect(() => {
if (mode == '3') {
setInnerValue(commentAdminRep ?? undefined);
} else {
setInnerValue(undefined);
}
}, [mode, commentAdminRep]);
const [messageApi, contextHolder] = message.useMessage();
const success = () => {
messageApi.open({
type: 'success',
content: 'Success!',
});
};
const handleOnChange = val => {
const { value } = val.target;
setInnerValue(value);
};
const callbacks = mode => {
const _a = {
'1': () => {
// go public with reply
changeParentMode('PublicReply');
},
'2': () => { },
'3': () => { },
'4': () => {
// go hidden with success
changeParentMode('HiddenSuccess');
},
'5': () => {
success();
// go public with failed report
changeParentMode('PublicFailed');
},
};
return _a[mode] ?? (() => { });
};
const submitDisabled =
innerMode != '3'
? !innerValue
: innerValue === (commentAdminRep ?? undefined) || !innerValue;
const titleClassName = classNames({
'title-required': required,
});
return (
<Modal
wrapClassName="comment-sun-modal-wrap"
className="comment-sun-modal"
visible={visible}
title={title}
onOk={() => {
setVisible(false);
onClose(false);
}}
onCancel={() => {
setVisible(false);
onClose(false);
}}
width={424}
style={{ top: 20 }}
bodyStyle={{ height: '232px', padding: '12px 12px 24px' }}
afterClose={() => {
if (innerMode != '3') {
setInnerValue(undefined);
} else {
setInnerValue(commentAdminRep ?? undefined);
}
}}
footer={[
<Button
key="submit"
type="primary"
disabled={submitDisabled}
onClick={() => {
setVisible(false);
onClose(false);
callbacks(innerMode)();
if (confirmCb) {
confirmCb(rowData?.id, innerValue);
console.log('innerValue:', innerValue);
}
setInnerValue(undefined);
}}
>
Confirm
</Button>,
]}
>
{contextHolder}
<div style={{ marginBottom: 10 }} className={titleClassName} data-testId='title'>
{hint}
</div>
<Input.TextArea
value={innerValue}
placeholder={placeholder}
showCount
maxLength={200}
style={{ height: 142 }}
onChange={handleOnChange}
/>
</Modal>
);
};
export default SunModal;
这个模态框的作用是当用户点击不同按钮之后弹出来,而点击不同的按钮,弹出来的模态框的内部状态是不确定的。弹出来之后我们可以输入一些内容,然后点击确认按钮,或者什么都不做直接关闭模态框。
这个组件的输入,解释如下:
- open: 控制模态框的显示或者隐藏
- onClose: 模态框关闭的时候的回调
- dataSource: 一部分详情数据
- changeParentMode: 调用此函数可以改变父组件中的一些状态
- confirmCb: 点击确认按钮之后的回调函数
- rowData: 剩余部分数据
2.2 测试设计
- 第一个测试,测试当 open 为 true 的时候,此组件是否能够正常渲染(关闭按钮,标题,副标题,文本框及占位符,确认按钮);
- 第二个测试,测试点击关闭按钮之后模态框是否会消失;
- 第三个测试时交互性测试,打开模态框之后,确认按钮是否置灰,输入文字后,文本框下方的字符统计是否正确,点击确认按钮之后相关函数有没有被调用、模态框是否关闭等。
2.3 测试文件
在 SunModal.js
的同一级目录中创建名为 SunModal.test.js
的测试文件,让我们先睹为快,看看测试文件的内容:
js
/* eslint-disable no-undef */ // 禁用 ESLint 中的 "no-undef" 规则,允许使用未声明的变量
// 导入 React 库,用于渲染组件
import React from "react";
// 导入 @testing-library/react 的函数,用于组件渲染和查询 DOM 元素
import { render, screen, within } from "@testing-library/react";
// 导入 @testing-library/user-event 的 user 对象,用于模拟用户交互
import user from "@testing-library/user-event";
// 导入要测试的 SunModal 组件
import SunModal from "./SunModal";
// 定义测试套件,描述测试的组件或功能
describe("Test Sun Modal", () => {
// 在每个测试用例执行前定义的变量
let rowData;
let dataSource;
// beforeAll 钩子,在所有测试用例执行前运行一次
beforeAll(() => {
// 重写 console.error 以避免测试过程中的控制台错误输出
console.error = () => { };
});
// beforeEach 钩子,在每个测试用例执行前运行
beforeEach(() => {
rowData = {
"id": "806875307314253824",
"userId": "393342074583257088",
"title": "nihaoads233",
"type": "3",
"cateId": "2",
"description": "hihahah",
"conditionType": "1",
"conditionValue": "Excellent",
"price": 0,
"phoneNumber": "+96611113111",
"facilityCityId": "239798951631392770",
"attachmentUrl": null,
"attachmentName": null,
"attachmentType": null,
"startDate": 1722873600000,
"expireDate": 1723478400000,
"displayPeriod": null,
"picList": [
{
"adsId": "806875307314253824",
"oriPicUrl": "/testminio/20240806133946809056019501027328.png",
"compressPicUrl": "/testminio/20240806133946809056023158460416.png",
"sort": 1,
"isCover": 1,
"createTime": 1722922852000
},
{
"adsId": "806875307314253824",
"oriPicUrl": "/testminio/806875307410722816..png",
"compressPicUrl": "/testminio/806875307326836736..png",
"sort": 2,
"isCover": 0,
"createTime": 1722922852000
},
{
"adsId": "806875307314253824",
"oriPicUrl": "/testminio/806875307670769664..png",
"compressPicUrl": "/testminio/806875307624632320..png",
"sort": 3,
"isCover": 0,
"createTime": 1722922852000
},
{
"adsId": "806875307314253824",
"oriPicUrl": "/testminio/20240806134002809056086047854592.png",
"compressPicUrl": "/testminio/20240806134002809056086878326785.png",
"sort": 4,
"isCover": 0,
"createTime": 1722922852000
}
],
"adsFieldList": null,
"customFieldMap": null,
"createTime": null,
"updateTime": 1722926770000,
"postTime": null,
"status": 3,
"queryStatus": null,
"cityName": "Abu Ali",
"typeName": "Free",
"categoryName": "Communication",
"poster": "testsss1 testss2 testss3 testsss1 testss2",
"sortType": null,
"statusStr": "Rejected",
"approveTime": 1722926763000,
"iconPathCompressed": null,
"compressPicUrl": null,
"operationLog": {
"adsId": "806875307314253824",
"operator": "admin",
"rejectReason": "撒潇洒",
"operateTime": 1722926770000
},
"attachmentFileName": null,
"attachmentSize": null,
"staffName": null,
"shareUrl": "/testminio/806875386880200704.html",
"oriAdsId": null
};
dataSource = {
"mode": "6",
"required": false,
"title": "Reject ad",
"hint": "Reject",
"placeholder": "Please enter your reason. It will be sent to user via notification.",
"value": null
};
})
// 测试用例:验证 SunModal 组件是否成功渲染
it("Should render successfully", async () => {
// 使用 render 函数渲染 SunModal 组件,并传入模拟的 props
render(<SunModal
open={true}
dataSource={dataSource}
rowData={rowData}
/>);
// 使用 screen.getByRole 查找关闭按钮元素,并断言它存在于文档中
const closeBtn = screen.getByRole('button', { name: /close/i });
expect(closeBtn).toBeInTheDocument();
// 使用 screen.getByText 查找包含特定文本的元素,并断言它存在于文档中
const title = screen.getByText(/reject ad/i);
expect(title).toBeInTheDocument();
// 使用 getByTestId 查找具有特定 data-testid 属性的元素
const subTitle = screen.getByTestId('title');
expect(subTitle).toBeInTheDocument();
// 检查子标题的文本内容是否包含 dataSource.hint 的值
expect(subTitle.textContent).toContain(dataSource.hint);
// 使用 getByRole 查找文本输入框,并断言它存在于文档中
const text = screen.getByRole('textbox');
expect(text).toBeInTheDocument();
// 断言文本输入框的 placeholder 属性是否与 dataSource.placeholder 匹配
expect(text.placeholder).toBe(dataSource.placeholder);
// 使用 getByRole 查找确认按钮,并断言它存在于文档中
const confirm = screen.getByRole('button', { name: /confirm/i });
expect(confirm).toBeInTheDocument();
// 断言确认按钮是否被禁用
expect(confirm.disabled).toBe(true);
// 记录测试的 URL,用于调试
screen.logTestingPlaygroundURL();
});
// 测试用例:验证关闭按钮是否正常工作
it("Close button works", async () => {
// 定义 onClose 函数,并使用 jest.fn() 来跟踪其调用情况
const onClose = jest.fn();
// 渲染 SunModal 组件,并传入 onClose 函数作为 prop
const { container } = render(<SunModal
open={true}
dataSource={dataSource}
rowData={rowData}
onClose={onClose}
/>);
// 使用 getByRole 查找对话框元素,并断言它存在于文档中
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
// 检查对话框的父元素的 display 样式是否为 'block'
expect(getComputedStyle(dialog.parentElement).display).toBe('block');
// 使用 getByRole 查找关闭按钮,并断言它存在于文档中
const closeBtn = screen.getByRole('button', { name: /close/i });
// 模拟用户点击关闭按钮
user.click(closeBtn);
// 断言 onClose 函数被调用,且调用时传递了 false
expect(onClose).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(false);
// 检查对话框的父元素的 display 样式是否变为 'none'
expect(getComputedStyle(dialog.parentElement).display).toBe('none');
// 记录测试的 URL,用于调试
screen.logTestingPlaygroundURL();
});
// 测试用例:验证组件是否能成功响应用户操作
it("Should react successfully", async () => {
// 定义模拟的拒绝理由字符串
const rejectReason = 'No Special Reason!';
// 定义 onClose, confirmCb, changeParentMode 函数,并使用 jest.fn() 跟踪调用
const onClose = jest.fn();
const confirmCb = jest.fn();
const changeParentMode = jest.fn();
// 渲染 SunModal 组件,并传入模拟的函数作为 props
const { container } = render(<SunModal
open={true}
onClose={onClose}
dataSource={dataSource}
changeParentMode={changeParentMode}
confirmCb={confirmCb}
rowData={rowData}
/>);
// 使用 getByRole 查找确认按钮,并断言它存在于文档中
const confirm = screen.getByRole('button', { name: /confirm/i });
// 断言确认按钮初始状态是禁用的
expect(confirm.disabled).toBe(true);
// 模拟用户点击确认按钮
user.click(confirm);
// 断言 onClose, confirmCb, changeParentMode 函数没有被调用
expect(onClose).not.toHaveBeenCalled();
expect(confirmCb).not.toHaveBeenCalled();
expect(changeParentMode).not.toHaveBeenCalled();
// 使用 getByRole 查找文本输入框
const text = screen.getByRole('textbox');
// 模拟用户在文本输入框中输入拒绝理由
user.type(text, rejectReason);
// 查找文本输入框的后缀元素,并断言它存在于文档中
const suffix = (text.parentElement).querySelector('.ant-input-data-count');
expect(suffix).toBeInTheDocument();
// 检查后缀元素的文本内容是否正确显示字符计数
expect(suffix.textContent).toBe('18 / 200');
// 再次使用 getByRole 查找确认按钮,并断言它现在应该被启用
const confirm2 = screen.getByRole('button', { name: /confirm/i });
expect(confirm2.disabled).toBe(false);
// 模拟用户再次点击确认按钮
user.click(confirm2);
// 断言 onClose 函数被调用,且调用时传递了 false
expect(onClose).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(false);
// 断言 confirmCb 函数被调用,且调用时传递了正确的参数
expect(confirmCb).toHaveBeenCalled();
expect(confirmCb).toHaveBeenCalledWith(rowData.id, rejectReason);
// 断言 changeParentMode 函数没有被调用
expect(changeParentMode).not.toHaveBeenCalled();
// 记录测试的 URL,用于调试
screen.logTestingPlaygroundURL();
});
});
2.4 测试技巧
本小节中,对上述测试文件中的技巧进行统计:
1. 导入最常用的两个测试库
js
// 导入 @testing-library/react 的函数,用于组件渲染和查询 DOM 元素
import { render, screen, within } from "@testing-library/react";
// 导入 @testing-library/user-event 的 user 对象,用于模拟用户交互
import user from "@testing-library/user-event";
2. 使用下面的技巧防止在 terminal 中输出过多的信息
js
// beforeAll 钩子,在所有测试用例执行前运行一次
beforeAll(() => {
// 重写 console.error 以避免测试过程中的控制台错误输出
console.error = () => { };
});
3. 在 describe 的顶层域中定义本域的全局变量,在 beforeEach 中对这些变量进行赋值。
4. 对待测组件进行优化,这有点 TDD 的意思,在测试过程中我发现待测组件最后自带 props 的默认值,这将极大的方便测试。
5. 使用 getBy + toBeInTheDocument 的方式对 UI 进行测试:
js
const title = screen.getByText(/reject ad/i);
expect(title).toBeInTheDocument();
6. 展示在页面上的值应该和输入值具有确定的对应关系/如何对 placeholder 进行断言
js
expect(text.placeholder).toBe(dataSource.placeholder);
7. 如何断言按钮处于 disabled 状态
js
expect(confirm.disabled).toBe(true);
8. 展示当前受测组件的状态
js
screen.logTestingPlaygroundURL();
9. 模拟一个函数,并利用此函数检测出调用时机和调用参数
js
const onClose = jest.fn();
expect(onClose).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(false);
// expect(onClose).not.toHaveBeenCalled();
// expect(confirmCb).not.toHaveBeenCalled();
// expect(changeParentMode).not.toHaveBeenCalled();
10. 接受 render 函数的返回之,并使用其查找元素
js
const { container } = render(<SunModal
open={true}
dataSource={dataSource}
rowData={rowData}
onClose={onClose}
/>);
// const confirm = container.querySelector('button');
11. 判断 Modal 是否成功渲染
js
// 使用 getByRole 查找对话框元素,并断言它存在于文档中
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
// 检查对话框的父元素的 display 样式是否为 'block'
expect(getComputedStyle(dialog.parentElement).display).toBe('block');
// 检查对话框的父元素的 display 样式是否变为 'none'
expect(getComputedStyle(dialog.parentElement).display).toBe('none');
12. 针对伪元素的断言
js
// 使用 getByRole 查找文本输入框
const text = screen.getByRole('textbox');
expect(getComputedStyle(text.parentElement, '::after').getPropertyValue('content')).toBe('18 / 200');
希望你能通过本文对 jest 测试有更加深入的了解,当然这只是开始,如果感兴趣的话欢迎关注,后续测试内容更加精彩!