React Vitest按钮点击与页面跳转测试失败问题排查
问题:Vitest测试React组件按钮点击与页面跳转失败
我正在使用Vitest测试AddVaultInstance组件的按钮点击及页面跳转功能,测试中按钮点击似乎未触发,且不知道如何验证点击后是否跳转到目标页面。当前测试Cancel按钮时出现报错:expected 'spy' to be called 1 times。
组件代码
import { TextInput, Stack, Form, SelectItem, Select, TextArea, Theme, Button } from '@carbon/react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; import { API_PREFIX } from '../../config/api-routes'; import styles from './AddVaultInstance.module.scss'; import { useSelector } from 'react-redux'; import React, { useState } from 'react'; import { isJsonString, onlyLettersAndNumbersAndUnderscodeAndHyphen, isValidConfigString } from '../../utils/ValidationUtil'; function AddVaultInstance() { const instanceOUapi = `${API_PREFIX}/instance`; const navigate = useNavigate(); const ouOwner = useSelector((state) => state.root.ouOwner); const [errors, setErrors] = useState({}); const options = { headers: { 'Content-Type': 'application/json', 'x-tenant': ouOwner } }; const formDataToJson = (formData) => { var object = {}; formData.forEach((value, key) => { if (!Reflect.has(object, key)) { object[key] = value; return; } if (!Array.isArray(object[key])) { object[key] = [object[key]]; } object[key].push(value); }); return object; }; const createInstancesByOu = async (formData) => { const res = await axios.post(instanceOUapi, formData, options); console.log('results', res); }; const handleSubmit = (e) => { e.preventDefault(); const form = e.target; const formData = new FormData(form); const formDataJson = formDataToJson(formData); // starting validation if (!onlyLettersAndNumbersAndUnderscodeAndHyphen(formDataJson.id)) { errors['id'] = true; } else { errors['id'] = false; } if (!onlyLettersAndNumbersAndUnderscodeAndHyphen(formDataJson.name)) { errors['name'] = true; } else { errors['name'] = false; } if (typeof formDataJson.instance_type === 'undefined') { errors['type'] = true; } else { errors['type'] = false; } if (!isJsonString(formDataJson.config)) { errors['config'] = true; } else { if ( isValidConfigString(formDataJson.instance_type, formDataJson.config) ) { errors['config'] = false; } else { errors['config'] = true; } } setErrors(errors); console.log(errors); if ( errors['id'] === false && errors['name'] === false && errors['type'] === false && errors['config'] === false ) { const configuration = JSON.parse(formDataJson.config); formDataJson.config = configuration; createInstancesByOu(formDataJson); navigate('/'); } }; const handleCancel = (e) => { e.preventDefault(); navigate('/'); }; return ( <> <Theme theme="white"> <Form className={styles.form} onSubmit={(e) => handleSubmit(e)}> <Stack gap={7}> <h3>Vault Registration</h3> <TextInput readOnly defaultValue="tenant" id="tenant" labelText="Tenant" name="tenant" style={{ border: '0', box: 'none', background: 'transparent' }} /> <TextInput readOnly defaultValue={ouOwner} id="orgnization_unit_id" labelText="Organization Unit" name="orgnization_unit_id" style={{ border: '0', box: 'none', background: 'transparent' }} /> <TextInput id="id" labelText="ID" name="id" placeholder="Vault Identification" invalid={errors.id} invalidText="Id only allows letters, numbers '-' or '_' and length from 8 to 1024 characters" required /> <TextInput id="name" labelText="Name" name="name" placeholder="Vault Name" invalid={errors.name} invalidText="Name only allows letters, numbers '-' or '_' and length from 8 to 1024 characters" required /> <Select id="instance_type" name="instance_type" labelText="Type" defaultValue="placeholder-item" helperText="Type of backing vault being registered" invalid={errors.type} invalidText="Please select a type" > <SelectItem disabled hidden value="placeholder-item" text="Select Vault Instance" /> <SelectItem value="ibmcloud" text="IBM Cloud Secrets Manager" /> <SelectItem value="hashicorp" text="Hashi Corp Vault" /> </Select> <TextArea name="config" labelText="Configuration" helperText="HostName, credentials etc" cols={50} rows={4} id="config" placeholder="Placeholder Text" invalid={errors.config} invalidText="Invalid JSON String." required /> <div className={styles.formActions}> <Button kind="secondary" onClick={handleCancel} role="cancel"> Cancel </Button> <Button type="submit">Create</Button> </div> </Stack> </Form> </Theme> </> ); } export default AddVaultInstance;
测试代码(原代码)
import React from 'react'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import react from '@vitejs/plugin-react'; import { useSelector } from 'react-redux'; import AddVaultInstance from '../../../src/components/instance/AddVaultInstance'; import { ticks } from 'd3'; const mockedNavigator = vi.fn(); const mockReactReduxx = vi.fn(); it.todo('render AddVaultInstance'); //console.log('myscreen: ', screen); vi.mock("react-redux", () => ({ useSelector: vi.fn() })); test('AddVaultInstance Verify Cancel Button ', () => { render(<AddVaultInstance />); // expect(screen.getByRole("cancel", { name: "Cancel" })).toBeDefined(); const mockCallBack = vi.fn(); //both works // name in by Text is nont case sensitive expect(screen.getByRole("cancel")).toBeDefined(); expect(screen.getByText(/cancel/i)).toBeDefined; }); test('AddVaultInstance Verify Create Button ', () => { render(<AddVaultInstance />); const mockCallBack = vi.fn(); //both works // name in by Text is nont case sensitive expect(screen.getByText(/Create/i)).toBeDefined; }); test('AddVaultInstance is rendering and Cancel Button function is being called', () => { render(<AddVaultInstance />); // expect(screen.getByRole("cancel", { name: "Cancel" })).toBeDefined(); const cancelButton = screen.getByText(/cancel/i); const handleCancel = vi.fn(); console.log("cancelButton: ", cancelButton); const isfired = fireEvent.click(cancelButton); console.log("isfired: ", isfired); expect(handleCancel).toHaveBeenCalledTimes(1); // TODO: how to test after click its navigating to this location "/" }); beforeEach(() => { vi.mock('react-router-dom', () => ({ ...vi.importActual('react-router-dom'), // technically it passes without this too, but I'm not sure if its there for other tests to use the real thing so I left it in useNavigate: () => mockedNavigator })); vi.mock('react-redux', () => ({ ...vi.importActual('react-redux'), useSelector: vi.fn((fn) => fn()) })); // to handle the const in the component vi.mock('react-redux', () => { const ouOwner = { data: { ouOwner: '34234.1' } } }); vi.mock('react-redux', () => ({ useSelector: vi.fn().mockImplementation((func) => func(ouOwner)) })); }); // end of beforeEach afterEach(() => { // restoring date after each test run vi.clearAllMocks(); }); // end of afterEach
问题分析与修复方案
1. 核心问题
- 测试中自定义的
handleCancelspy未关联到组件内部的handleCancel方法,点击按钮自然不会触发这个spy,导致报错。 beforeEach中重复多次mockreact-redux,后一次mock会覆盖前一次,导致useSelector的mock失效,组件无法获取正确的ouOwner值。- 未正确利用
useNavigate的mock函数来验证页面跳转行为。
2. 修复步骤
步骤1:统一Mock依赖,避免重复覆盖
修改beforeEach,确保只mock一次react-redux和react-router-dom,并设置正确的返回值:
beforeEach(() => { // Mock react-router-dom的useNavigate,返回跟踪调用的mock函数 vi.mock('react-router-dom', () => ({ ...vi.importActual('react-router-dom'), useNavigate: () => mockedNavigator })); // Mock react-redux的useSelector,返回符合组件结构的ouOwner值 vi.mock('react-redux', () => ({ ...vi.importActual('react-redux'), useSelector: vi.fn().mockImplementation((selector) => { // 模拟组件需要的state结构 const mockState = { root: { ouOwner: '34234.1' } }; return selector(mockState); }) })); });
步骤2:调整Cancel按钮测试逻辑
不需要自定义无关的handleCancel spy,直接断言mockedNavigator被调用,因为组件的handleCancel最终会调用navigate('/'):
test('Cancel按钮点击后触发页面跳转', () => { render(<AddVaultInstance />); const cancelButton = screen.getByRole('cancel'); fireEvent.click(cancelButton); // 验证navigate被调用1次,且参数为目标路径'/' expect(mockedNavigator).toHaveBeenCalledTimes(1); expect(mockedNavigator).toHaveBeenCalledWith('/'); });
步骤3:验证Create按钮的跳转逻辑(可选)
如果要测试Create按钮的跳转,需要先填充表单通过验证,再点击按钮:
test('Create按钮验证通过后跳转到首页', async () => { render(<AddVaultInstance />); // 填充表单 fireEvent.change(screen.getByLabelText('ID'), { target: { value: 'valid_id_123' } }); fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'valid_name_123' } }); fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'ibmcloud' } }); fireEvent.change(screen.getByLabelText('Configuration'), { target: { value: JSON.stringify({ host: 'test-host' }) } }); // 点击Create按钮 fireEvent.click(screen.getByText(/create/i)); // 等待异步操作完成,验证跳转 await waitFor(() => { expect(mockedNavigator).toHaveBeenCalledWith('/'); }); });
3. 完整修复后的测试代码
import React from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import AddVaultInstance from '../../../src/components/instance/AddVaultInstance'; const mockedNavigator = vi.fn(); it.todo('render AddVaultInstance'); test('页面渲染Cancel按钮', () => { render(<AddVaultInstance />); expect(screen.getByRole('cancel')).toBeInTheDocument(); expect(screen.getByText(/cancel/i)).toBeInTheDocument(); }); test('页面渲染Create按钮', () => { render(<AddVaultInstance />); expect(screen.getByText(/create/i)).toBeInTheDocument(); }); test('Cancel按钮点击后跳转到首页', () => { render(<AddVaultInstance />); const cancelButton = screen.getByRole('cancel'); fireEvent.click(cancelButton); expect(mockedNavigator).toHaveBeenCalledTimes(1); expect(mockedNavigator).toHaveBeenCalledWith('/'); }); test('Create按钮验证通过后跳转到首页', async () => { render(<AddVaultInstance />); fireEvent.change(screen.getByLabelText('ID'), { target: { value: 'valid_id_123' } }); fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'valid_name_123' } }); fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'ibmcloud' } }); fireEvent.change(screen.getByLabelText('Configuration'), { target: { value: JSON.stringify({ host: 'test-host' }) } }); fireEvent.click(screen.getByText(/create/i)); await waitFor(() => { expect(mockedNavigator).toHaveBeenCalledWith('/'); }); }); beforeEach(() => { vi.mock('react-router-dom', () => ({ ...vi.importActual('react-router-dom'), useNavigate: () => mockedNavigator })); vi.mock('react-redux', () => ({ ...vi.importActual('react-redux'), useSelector: vi.fn().mockImplementation((selector) => { const mockState = { root: { ouOwner: '34234.1' } }; return selector(mockState); }) })); }); afterEach(() => { vi.clearAllMocks(); });
内容的提问来源于stack exchange,提问作者vicky
相关产品推荐
相关产品推荐

