jest(写UT总结版) 常用命令 1 2 3 4 5 6 7 8 npm run test npm run test :watch 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 ()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" ); const selectOneDom = screen.getByText ("Selected Dom Label" );const selectOneOption = await screen.findByText ("Type Option Label" ); const optionsElement = screen.getByRole ("listbox" ); const AllClickBtn = screen.getAllByRole ("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 ();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 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' )sconst 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' )expect (expiredDatePickerElement).toBeInTheDocument ()fireEvent.click (expiredDatePickerElement) 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) }) 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 })
jest语法 测试用例应遵循 “arrange-act-assert” 模式(准备 - 执行 - 断言)
基础框架 1 2 3 4 5 6 7 8 9 10 11 12 13 describe ('CanvasComponent' , () => { 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 (() => { console .log ('全局设置 - 在所有测试前运行' ); }); beforeEach (() => { render (<CanvasDrawer /> ); foregroundCanvas = screen.getByTestId ('foreground-canvas' ); console .log ('测试前准备 - 在每个测试前运行' ); }); afterEach (() => { console .log ('测试后清理 - 在每个测试后运行' ); }); 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 (), strokeRect : jest.fn (), }; } 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 ('元素获取' , () => { 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' ); 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 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 ) ); 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 ('覆盖率增强' , () => { try { } catch (e) { } const { unmount } = render (<CanvasComponent /> ); unmount (); });
注意事项:避免 “为覆盖率而测试” 提高覆盖率的同时,需避免盲目追求数字而编写写无意义的测试: 不测试明显不会逻辑的代码(如简单的 getter/setter) 不重复测试相同逻辑(如多个测试用例覆盖同一分支) 优先覆盖核心逻辑(如绘图核心逻辑),再非边缘的辅助代码
间谍(Spies)与模拟(Mocks) 理解:
间谍(Spies)是 Jest 提供的一种特殊函数,用于监视其他函数的调用情况: 记录函数被调用的次数 记录函数被调用时的参数 默认情况下不影响原函数的正常执行
模拟(Mocks)是 Jest 提供的一种 “假函数”,用于替代真实函数: 完全替换原函数,返回预设值 记录自身被调用的情况 用于隔离测试,避免依赖外部资源
间谍与模拟的区别 间谍:只监视,不改变原函数行为(监控 Canvas 上下文方法调用) 模拟:完全替换原函数,可自定义返回值(较少使用,除非需要替换复杂逻辑)
为什么在 Canvas 测试中需要它们 在Canvas 绘图应用中,无法直接 “看到” 绘制的图形,但可以通过监视Canvas上下文的方法调用情况,来验证绘图功能是否正常工作
例如,当调用 context.fillRect() 时,间谍可以告诉我们: 这个方法是否被调用了;调用时使用了哪些参数(位置、大小等);调用了多少次
语法
创建间谍const fillRectSpy = jest.spyOn(ctx, 'fillRect');
ctx 是你获取的 Canvas 2D 上下文 ‘fillRect’ 是你要监视的方法名 这个操作不会改变 fillRect 的原有功能,只是增加了监控
断言间谍记录1 2 3 4 5 6 7 8 expect (fillRectSpy).toHaveBeenCalled ();expect (fillRectSpy).toHaveBeenCalledTimes (1 );expect (fillRectSpy).toHaveBeenCalledWith (10 , 10 , 50 , 50 );
清理间谍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 ();