jest(写UT总结版)

常用命令

1
2
3
4
5
6
7
8
#运行生成测试报告文件--报告在coverage/report/父组件/子组件/index.tsx.html
npm run test
# 运行测试监听
npm run test:watch
# 然后输入w p 当前文件所在文件夹->只监听这一个
# 删除整个文件夹
# 重新生成组件时,每个的报告也覆盖进来
rm -rf coverage/

核心步骤

导入组件->describe() -><router><组件/></router>(旧的,现在路由改了不用这个方法)
测试页面初始化;
点击页面->浏览器找到用到了哪些接口->看路径->复制路径代码找接口->把接口放进导入到 jest 文件
模拟接口要传的参数->组件页面中找接口->找接口的每一个传参;
如果是页面参数->不管
如果是缓存参数->找它的 store->把 store 定义的参数放进 jest->如果是必须有的模拟一个值
看接口的返回值->F12 找返回值(可能有一堆)->回到代码看->必须的写进入,非必须的不管它

注意

  • 传给接口的数据:缓存中的数据模拟,页面的数据不需要模拟
  • 注意前置条件的模拟
  • 解密的数据模拟的时候要加密
  • 如果是标签组件,每个标签页一个子组件,只有父组件有路由,测试时<router><包裹父组件></router>,因为子组件没有自己的路由,所以能触发到该子组件 Tab 的条件也要写进来,要赋值(判断值->走到该子组件)
  • 有些方法用到的接口不知道返回数据的格式/必填项,去用到数据的地方找,比如返回给 Table 数据的地方去看 Table
  • 看对返回数据是否进行处理->判断返回[ ]或{ }
  • 找接口,路径传参参考。正常路径传参有 Payload,路径传参没有这一块儿
  • Table 测行,表头算一行,如果没数据,no data 也算一行,肯定有两行,所以给三行数据测三行

准备->导入->mock 接口->模拟返回->模拟缓存传参

初始化接口

初始化接口全部写在 beforeEach(()=>{}) 里面
接口.mockClear() 先清空接口返回的东西
接口.mockReturnValue() 模拟返回的数据,非必须的写格式,必须的写出来给值
form 的值从哪儿来,注意 name

1
2
3
4
5
6
api.mockClear();
api.mockReturnValue(
new Promise((resolve) => {
resolve({ status: "success", data: [] });
})
);

常用导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
jest.mock('@/api')
jest.mock('@/api/common')
jest.mock('@/api/proposal')
console.error = jest.fn()

//导入utils中所有的,重写handleAutoDownload
jest.mock('@/utils', () => ({
...jest.requireActual('@/utils'),
handleAutoDownload: jest.fn(() =>
Promise.resolve({ status: 200, message: 'Download successfully!' })
)
}))

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn()
}))

jest.mock('sockjs-client/dist/sockjs.min.js', () =>
jest.fn().mockImplementation(() => ({
onopen: jest.fn(),
onmessage: jest.fn(),
onclose: jest.fn(),
send: jest.fn(),
close: jest.fn()
}))
)
jest.mock('stompjs', () => ({
over: jest.fn(() => ({
connect: jest.fn(),
subscribe: jest.fn(),
disconnect: jest.fn()
}))
}))

afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})

mock 文件返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
useLocation.mockReturnValue({
state: { from: '' }
})
ExportCostSummary.mockReturnValue(
new Promise((resolve, reject) => {
resolve({
blob: () =>
Promise.resolve(
new Blob(['test content'], { type: 'text/plain' })
),
fileName: 'test Name'
})
})
)

新版导入组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
renderWithProviders(<SwiftXProcess />, {
routePath: '/case-information',
preloadedState: {
pageInfo: { caseId: '196' },
userInformation: {
rolePermissions: encryptAES([]),
userCode: encryptAES('')
},
role: {
currentRole: {
roleName: 'AE'
}
}
}
})

获取被测组件

  • 在代码中给要测的组件加 data-testid=’xxx’ -> 获取 screen.getByTestId(‘xxx’)
  • 获取操作后渲染的 DOM 用 findBy,页面上原本的用 getBy
  • jest findByText 的底层运用的是 textContent 默认行为是去掉文字首尾空格,如果测试文字带有空格结尾可能导致无法匹配,从而引发报错 如果一定要处理空格结尾数据,normalizer 自定义配置
  • 如果 data-testid 给组件加不上,可以这样
    • render={(item) => <span data-testid="GL-existing-modal-transfer">{item.label}</span>}
  • 如果有 label -> screen.getByText(‘hello-label’)
  • 如果只有 role 和 classname,同时同 role 的按钮有多个 -> getAllByRole
1
2
3
4
const AllClickBtn = screen.getAllByRole("img");
const clickBtn = AllClickBtn.find((item) =>
item.classList.contains("anticon-right")
);

Button 的 display 属性为 false 那么按钮可点击,为 true 禁用

常用获取 API

1
2
3
4
5
6
7
8
9
10
11
const DatePicker = screen.getByTestId("DatePicker");
const formItemElement = within(
screen.getByTestId("combined-underwriting")
).getByRole("combobox");
const debounceSelectInput = within(debounceSelect).getByRole("combobox"); // 搜索选择器
const debounceSelectInput = within(debounceSelect).getByRole("combobox"); // input的type属性->getByRole('combobox')
const selectOneDom = screen.getByText("Selected Dom Label");

const selectOneOption = await screen.findByText("Type Option Label"); // 获取操作后渲染的DOM用find,比如菜单选项的某一项
const optionsElement = screen.getByRole("listbox"); // 选项的type->getByRole('listbox')
const AllClickBtn = screen.getAllByRole("img"); // 图片&图标的type->getByRole('img')

判断存在 & 判断是否可点击

1
2
3
4
5
6
7
8
9
10
11
// 断言判断是否存在
expect(screen.getByTestId("dashboard-component")).toBeInTheDocument();
// 分开写清楚
const caseNoElement = screen.getByTestId("case-no-div");
expect(caseNoElement).toBeInTheDocument();
// 是否可点击
expect(exportCostSummaryBtn).not.toBeDisabled();
// Table判断行数,表头算一行,如果没数据,no data也算一行,肯定有两行,所以给三行数据测三行
await waitFor(() => {
expect(screen.getAllByRole("row")).toHaveLength(3);
});

触发组件

  • 输入 / 点击事件: fireEvent.动作(操作的DOM)
  • 输入 / 点击事件要用 await act(()=>{放进来})
  • 失焦事件: fireEvent.blur(inputEvent);
  • 触发回车键事件: fireEvent.keyDown(input, { key: ‘Enter’, code: ‘Enter’, keyCode: 13, which: 13 });
  • 如果触发失败 -> 打印组件 -> 看有什么绑定的的方法 -> 用 click&mousedown && 用 change&input

    ant design 组件的 select 组件无法被 click 触发,可以被 mouseDown 触发,原因:
    它不是真正的 select 组件,在页面上是 div,div 上没有绑定 click,有 mouseDown 触发事件

常用的触发 API

1
2
3
4
5
6
7
8
9
await waitFor(() => {
fireEvent.click(clickBtn);
fireEvent.mouseDown(debounceSelectInput);
fireEvent.input(debounceSelectInput, { target: { value: "Catherine" } });
fireEvent.keyDown(enterInput, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 });
fireEvent.focus(targetDateCSDatePicker);s
fireEvent.change(targetDateCSDatePicker, { target: { value: "08/20/2025" } });
fireEvent.blur(targetDateCSDatePicker);
});

组件->API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// Table测行,表头算一行,如果没数据,no data也算一行,肯定有两行,所以给三行数据测三行
const GLExistingTable = screen.getByTestId('GL-load-existing-table')
const GLRows = within(GLExistingTable).getAllByRole('row')
expect(GLRows).toHaveLength(3)

// 搜索选择器
const debounceSelectInput = within(debounceSelect).getByRole('combobox')
await waitFor(() => {
fireEvent.mouseDown(debounceSelectInput)
})
await waitFor(() => {
fireEvent.input(debounceSelectInput, { target: { value: 'Catherine' } })
})
const selectedOption = await screen.findByText('Catherine Nie')
expect(selectedOption).toBeInTheDocument()
await waitFor(() => {
fireEvent.click(selectedOption)
})

// 多个多选框,用数组拿到所有框,再找要拿的
const selectedOptions = within(screen.getByTestId('channel-table')).getAllByRole('checkbox')s
const selectOptionInput = selectedOptions[1]
expect(targetDateCSDatePicker).toBeInTheDocument()
expect(targetDateCSDatePicker).not.toBeDisabled()

// 日期选择器最佳办法---找到日期选择器组件
const effectiveDateElement = screen.getByTestId('effectiveDatePicker')
expect(effectiveDateElement).toBeInTheDocument()
// 点开组件,展开日期
fireEvent.click(effectiveDateElement)
await waitFor(() => {
// 拿到日期选择部分
expect(document.querySelector('.ant-picker-panel')).toBeInTheDocument()
})
// 拿到目前组件页展示的所有的单个日期
const cells = document.querySelectorAll('.ant-picker-cell-inner')
// 找到要选的日期,点他
const targetCell = Array.from(cells).find(
(cell) => cell.textContent === '12'
)
await waitFor(() => {
fireEvent.click(targetCell)
})

// 多个时间选择器,上面操作的是第一个,现在我要找到第二个
const expiredDatePickerElement = screen.getByTestId('expiredDatePickerElement')
// 刚才只点了一个日期选择器,所以页面上只生成了一个包含'.ant-picker-panel'的div
// 现在点第二个日期选择器,会生成一个新的包含'.ant-picker-panel'的div
expect(expiredDatePickerElement).toBeInTheDocument()
fireEvent.click(expiredDatePickerElement)
// 所以这次要选中所有的'.ant-picker-panel',再通过数组拿到第二个,操作它,后面的和之前一样
const panels = document.querySelectorAll('.ant-picker-panel')
const secondPanel = panels[1]
await waitFor(() => {
expect(document.querySelector('.ant-picker-panel')).toBeInTheDocument()
})
const cellsInSecondPanel = secondPanel.querySelectorAll('.ant-picker-cell-inner')
const expiredDatecell = Array.from(cellsInSecondPanel).find(
(cell) => cell.textContent === '15'
)
await waitFor(() => {
fireEvent.click(expiredDatecell)
})

// 日期选择器,用focus-change-blur来做时好时不好
const effectiveDateElement = screen.getByTestId('effectiveDatePicker')
expect(effectiveDateElement).toBeInTheDocument()
await waitFor(() => {
fireEvent.focus(effectiveDateElement)
fireEvent.change(effectiveDateElement, { target: { value: '08/12/2025' } })
fireEvent.blur(effectiveDateElement)
})

// 下载文件
jest.mock('@/utils', () => ({
handleAutoDownload: jest.fn(() => Promise.resolve({ status: 200, message: 'Download successfully!' }))
}));
DownloadReportManagementFile.mockClear()
DownloadReportManagementFile.mockReturnValue(
new Promise((resolve, reject) => {
resolve({
blob: () => Promise.resolve(new Blob(['test content'], { type: 'text/plain' })),
fileName: 'test Name'
})
})
)

// 上传文件
const uploadFileInput = screen.getByTestId('upload-pdf')
await waitFor(() => {
expect(uploadFileInput).toBeInTheDocument()
expect(uploadFileInput).not.toBeDisabled()
})

const mockTestFile = new File(['mock pdf content'], 'test.pdf', {
type: 'application/pdf'
})
await waitFor(() => {
fireEvent.change(uploadFileInput, {
target: { files: [mockTestFile] }
})
})

await waitFor(() => {
expect(screen.findByText('test.pdf')).toBeInTheDocument()
// 如果有其他按钮可显示
const deletePdfBtn = screen.getByTestId('delete-pdf-btn')
expect(deletePdfBtn).toBeInTheDocument()
fireEvent.click(deletePdfBtn)
})

报错 & 警告

  • TypeError: Expected container to be an Element, a Document or a DocumentFragment but got string.
  • The given element does not have a value setter
  • -可能是 testid 没有被加到对应组件上,加到了它父亲的 div 上,div 不能直接输入,要找到它下面的 input 再输入
  • Warning: [antd: DatePicker] 'popupClassName' is deprecated. Please use 'classNames.popup.root' instead.
  • 警告: [antd: DatePicker] popupClassName 已弃用。请改用 classNames.popup.root
  • Access to XMLHttpRequest at 'http://localhost:8990/SWIFTX_COMMON/secure/dropdown/groups' from origin 'http://localhost:5005' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
    • 跨域问题
  • try-catch 如果走到了 catch,打印 error 看到的不够清晰,继续看,打印 console.log(error.errorFields[0]) 可以更准确拿到错误
    • sconst selectedOption = await screen.findByText('8008088720 - (0) DUMMY - SHAU KEI WAN BRANCH', { exact:true, normalizer: (text) => text })
    • 如果超时了,可以这样 }, 50000)

jest语法

测试用例应遵循 “arrange-act-assert” 模式(准备 - 执行 - 断言)

基础框架

1
2
3
4
5
6
7
8
9
10
11
12
13
// describe: 创建测试套件,将相关测试分组
describe('CanvasComponent', () => {

// test 或 it: 定义单个测试用例
test('正确渲染组件及其子元素', () => {
// 测试实现...
});

// 另一种写法 (it 是 test 的别名)
it('能够绘制矩形', () => {
// 测试实现...
});
});

生命周期钩子函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// beforeAll: 在所有测试运行前执行一次
beforeAll(() => {
// 通常用于全局模拟设置
console.log('全局设置 - 在所有测试前运行');
});

// beforeEach: 每个测试用例执行前运行,提取测试用例之间的重复初始化代码:
beforeEach(() => {
render(<CanvasDrawer />);
foregroundCanvas = screen.getByTestId('foreground-canvas');
// 重置模拟函数状态
console.log('测试前准备 - 在每个测试前运行');
});

// afterEach: 在每个测试运行后执行
afterEach(() => {
// 清理操作
console.log('测试后清理 - 在每个测试后运行');
});

// afterAll: 在所有测试运行后执行一次
afterAll(() => {
// 全局清理
console.log('全局清理 - 在所有测试后运行');
});

模拟函数和方法(?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 模拟整个模块
jest.mock('../path/to/module', () => ({
exportedFunction: jest.fn(),
}));

// 模拟对象方法
HTMLCanvasElement.prototype.getContext = jest.fn().mockImplementation((type) => {
if (type === '2d') {
return {
fillRect: jest.fn(), // 模拟 fillRect 方法
strokeRect: jest.fn(), // 模拟 strokeRect 方法
// 其他方法...
};
}
return null;
});

// 创建模拟函数
const mockFunction = jest.fn();

// 设置模拟函数返回值
mockFunction.mockReturnValue(42);

// 设置模拟函数实现
mockFunction.mockImplementation((a, b) => a + b);

// 重置模拟函数状态
beforeEach(() => {
mockFunction.mockClear(); // 清除调用记录但保留实现
// 或者
mockFunction.mockReset(); // 完全重置模拟函数
});

元素获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { render, screen } from '@testing-library/react';
test('元素获取', () => {
// 渲染组件到虚拟DOM
render(<CanvasComponent />);

// 通过角色查询单个元素
const canvas = screen.getByRole('canvas');

// 通过角色查询多个元素
const canvases = screen.getAllByRole('canvas');

// 通过文本内容查询
const rectButton = screen.getByText('矩形');

// 通过标签文本查询
const colorInput = screen.getByLabelText('选择颜色');

// 通过占位符查询
const textInput = screen.getByPlaceholderText('输入文本');

// 通过显示值查询
const fontSizeInput = screen.getByDisplayValue('16');

// 通过测试ID查询 (推荐用于动态内容)
const statusElement = screen.getByTestId('drawing-status');

// 异步查询元素 (等待元素出现)
const asyncElement = await screen.findByText('加载完成');
});

用户交互

userEvent:更贴近真实用户行为的交互模拟
fireEvent:更底层的事件触发方法,可传递详细事件参数
异步操作需要使用 await 等待完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { fireEvent } from '@testing-library/react';

test('事件模拟示例', () => {
render(<CanvasComponent />);

// 模拟点击事件
fireEvent.click(screen.getByText('矩形'));

// 模拟鼠标按下
fireEvent.mouseDown(canvasElement, {
clientX: 100,
clientY: 100,
buttons: 1 // 表示左键按下
});

// 模拟鼠标移动
fireEvent.mouseMove(canvasElement, {
clientX: 200,
clientY: 200,
buttons: 1
});

// 模拟鼠标释放
fireEvent.mouseUp(canvasElement);

// 模拟右键菜单
fireEvent.contextMenu(canvasElement, { clientX: 300, clientY: 300 });

// 模拟输入变化
fireEvent.change(inputElement, { target: { value: '新值' } });

// 模拟键盘事件
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.keyDown(inputElement, { key: 'Enter' });

// 模拟焦点事件
fireEvent.focus(inputElement);
fireEvent.blur(inputElement);
});

断言

存在断言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 断言元素在文档中存在
expect(canvas).toBeInTheDocument();

// 断言元素不存在
expect(invisibleElement).not.toBeInTheDocument();

// 断言方法被调用过
expect(beginPathSpy).toHaveBeenCalled();

// 断言方法被调用过特定次数
expect(strokeSpy).toHaveBeenCalledTimes(1);

// 断言方法被调用时使用了特定参数
expect(rectSpy).toHaveBeenCalledWith(50, 50, 100, 50);

// 断言属性被设置为特定值
expect(fillStyleSpy).toHaveBeenCalledWith('#ff0000');

/ 元素包含类名
expect(element).toHaveClass('active');

// 元素包含特定文本
expect(element).toHaveTextContent('绘制中');

// 断言状态值为预期值,值相等 (严格比较)
expect(canvasRef.current.mode).toBe('RECT');
expect(canvasRef.current.isDrawing).toBe(true);
expect(value).toBe(42);

// 对象结构相等 (深度比较)
expect(obj).toEqual({ x: 100, y: 100 });

// 断言数值大于某个值
expect(canvas.width).toBeGreaterThan(0);

// 断言字符串包含特定子串
expect(strokeStyle).toContain('red');

// 正则匹配
expect(text).toMatch(/成功/);

// 错误抛出
expect(() => errorFunc()).toThrow('错误信息');

异步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 测试函数前加 async,内部可使用 await
import { waitFor } from '@testing-library/react';

test('异步操作测试', async () => {
render(<CanvasComponent />);

// 触发异步操作
fireEvent.click(screen.getByText('加载数据'));

// 等待元素出现
const result = await screen.findByText('加载完成');

// 等待断言成立
await waitFor(() => {
expect(screen.getByText('操作成功')).toBeInTheDocument();
});

// 等待特定条件
await waitFor(() =>
expect(mockAPI).toHaveBeenCalledTimes(1)
);

// 使用 resolves/rejects 测试 Promise
await expect(Promise.resolve('成功')).resolves.toBe('成功');
await expect(Promise.reject('错误')).rejects.toMatch('错误');
});

其他

辅助函数

提取重复计算逻辑为辅助函数

1
2
3
const calculateDistance = (x1, y1, x2, y2) => {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
};

测试隔离

确保每个测试用例都是独立的,避免测试间的副作用传递

1
2
3
beforeEach(() => {
jest.clearAllMocks();
});

测试覆盖率增强技巧

核心思路是:从 “功能点”、”分支条件”、”边界场景” 三个维度度全面覆盖,利用工具定位盲区,结合项目实际逻辑补充测试

测试所有条件分支;测试错误处理;函数覆盖率(工具函数、事件处理函数、钩子函数);”正常场景” 外的边界情况(如极限值、空值、错误输入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test('覆盖率增强', () => {
// 测试所有条件分支
// if-else, switch-case 的所有分支

// 测试循环边界
// 0次迭代、1次迭代、多次迭代

// 测试错误处理
try {
// 可能抛出错误的操作
} catch (e) {
// 验证错误处理
}

// 测试组件卸载
const { unmount } = render(<CanvasComponent />);
unmount();
// 验证清理操作
});

注意事项:避免 “为覆盖率而测试”
提高覆盖率的同时,需避免盲目追求数字而编写写无意义的测试:
不测试明显不会逻辑的代码(如简单的 getter/setter)
不重复测试相同逻辑(如多个测试用例覆盖同一分支)
优先覆盖核心逻辑(如绘图核心逻辑),再非边缘的辅助代码

间谍(Spies)与模拟(Mocks)

理解:

  1. 间谍(Spies)是 Jest 提供的一种特殊函数,用于监视其他函数的调用情况:
    记录函数被调用的次数
    记录函数被调用时的参数
    默认情况下不影响原函数的正常执行
  2. 模拟(Mocks)是 Jest 提供的一种 “假函数”,用于替代真实函数:
    完全替换原函数,返回预设值
    记录自身被调用的情况
    用于隔离测试,避免依赖外部资源

间谍与模拟的区别

间谍:只监视,不改变原函数行为(监控 Canvas 上下文方法调用)
模拟:完全替换原函数,可自定义返回值(较少使用,除非需要替换复杂逻辑)

为什么在 Canvas 测试中需要它们

在Canvas 绘图应用中,无法直接 “看到” 绘制的图形,但可以通过监视Canvas上下文的方法调用情况,来验证绘图功能是否正常工作

例如,当调用 context.fillRect() 时,间谍可以告诉我们:
这个方法是否被调用了;调用时使用了哪些参数(位置、大小等);调用了多少次

语法

  1. 创建间谍
    const fillRectSpy = jest.spyOn(ctx, 'fillRect');

    ctx 是你获取的 Canvas 2D 上下文
    ‘fillRect’ 是你要监视的方法名
    这个操作不会改变 fillRect 的原有功能,只是增加了监控

  2. 断言间谍记录
    1
    2
    3
    4
    5
    6
    7
    8
    // 验证方法被调用过
    expect(fillRectSpy).toHaveBeenCalled();

    // 验证方法被调用了特定次数
    expect(fillRectSpy).toHaveBeenCalledTimes(1);

    // 验证方法被调用时使用了特定参数
    expect(fillRectSpy).toHaveBeenCalledWith(10, 10, 50, 50);
  3. 清理间谍
    fillRectSpy.mockRestore();
    1
    2
    3
    afterEach(() => {
    jest.clearAllMocks(); // 清除所有间谍的调用记录
    });

在代码中需要使用间谍的地方:

验证图形绘制方法调用

如:选择矩形工具绘制时,验证 rect() 和 stroke() 方法是否被正确调用

1
2
3
4
5
6
7
8
9
10
11
// 监视矩形绘制相关方法
const rectSpy = jest.spyOn(ctx, 'rect');
const strokeSpy = jest.spyOn(ctx, 'stroke');

// 执行绘制操作
fireEvent.click(canvas, { clientX: 50, clientY: 50 });
fireEvent.click(canvas, { clientX: 150, clientY: 100 });

// 验证方法调用
expect(rectSpy).toHaveBeenCalledWith(50, 50, 100, 50);
expect(strokeSpy).toHaveBeenCalled();