You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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. 核心问题

  • 测试中自定义的handleCancel spy未关联到组件内部的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.18 21:05:22