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

React Testing Library中正确mock setState及调用次数为0问题排查

React Testing Library测试中setInputError未被调用的问题

我正尝试使用React Testing Library来mock React项目index.js文件中的setState函数。在注册场景中,当两个密码输入框内容不匹配时,本应调用setInputError状态来提示用户,但编写测试时发现该函数的调用次数为0。

组件代码(index.js)

const [inputError, setInputError] = useState({
    email: false,
    confirmEmail: false,
    password: false,
    confirmPassword: false,
    passwordMatch: false,
});

const checkInputErrors = useCallback(
    (inputPassword, confirmInputPassword) => {
        const inputError = {
            passwordMatch: inputPassword !== confirmInputPassword,
            password: !checkPassword(inputPassword),
            confirmPassword: inputPassword !== confirmInputPassword || confirmInputPassword === "",
        };

        return inputError;
    },
    []
);

const registerAccountHandler = useCallback(async () => {
    const inputError = checkInputErrors(
        inputPassword,
        confirmInputPassword,
    );
    const inputErrorFound = Object.values(inputError).some((error) => error);

    if (inputErrorFound) {
        setInputError(inputError);
        return;
    }

    const { error } = await register({
        password: inputPassword,
    });

    if (!error) {
        historyObject.replace("/subscribe/plans");
    } else {
        toast.error(t(`error.${error.data?.title}`, error.data?.title));
    }
}, [
    t,
    register,
    inputPassword,
    confirmInputEmail,
    confirmInputPassword,
    checkInputErrors,
]);


<div className="input-container">
    <TextField
        title={t("pages.login.signup.password")}
        label={t("pages.login.signup.confirm_password_placeholder")}
        value={inputPassword}
        onChange={(val) => {
            setInputPassword(val);
            setInputError(false);
        }}
        validation={inputError.password && { status: validationStatuses.ERROR }}
    />
    {renderCriteriaMetNotifications}
    <TextField
        title={t("pages.login.signup.confirm_password")}
        label={t("pages.login.signup.confirm_password_placeholder")}
        value={confirmInputPassword}
        onChange={(val) => {
            setConfirmInputPassword(val);
            setInputError(false);
        }}
        validation={inputError.confirmPassword && { status: validationStatuses.ERROR }}
    />
</div>

测试文件代码

import React from "react";
import { render, fireEvent } from "@testing-library/react";
import SignupPage from "./index";
import * as redux from "react-redux";
jest.mock("historyObject");
jest.mock("services/api/login");
import { loginApiService } from "services/api/login";

describe("SignupPage", () => {
    const props = {
        t: jest.fn(),
        inputError: {
            email: false,
            confirmEmail: false,
            password: false,
            confirmPassword: false,
            passwordMatch: false,
            acceptTerms: false,
        },
        setInputError: jest.fn(),
    };

    beforeEach(() => {
        jest.resetAllMocks();
        jest.spyOn(React, "useEffect")
            .mockImplementationOnce((x) => x())
            .mockImplementationOnce((x) => x())
            .mockImplementationOnce((x) => x())
            .mockImplementationOnce((x) => x());
        loginApiService.useUserSignupMutation.mockReturnValue([jest.fn(), { isLoading: false }]);
    });


it("should display error message when passwords do not match", async () => {
        const setInputError = jest.fn();
        const props = { setInputError };
        const { getByLabelText, getByText } = render(<SignupPage {...props} />);
        const passwordInput = getByLabelText("pages.login.signup.password");
        const confirmPasswordInput = getByLabelText("pages.login.signup.confirm_password");
        const registerButton = getByText("pages.login.signup.register");
        fireEvent.change(passwordInput, { target: { value: "password1" } });
        fireEvent.change(confirmPasswordInput, { target: { value: "password2" } });
        fireEvent.click(registerButton);

        expect(setInputError).toHaveBeenCalledWith({
            ...props.inputError,
            passwordMatch: true,
        });
    });

});

错误情况

测试报错,提示setInputError的调用次数为0。

问题原因及解决方案

核心问题1:组件内部状态与props混淆

你的组件内部通过useState声明了inputError和setInputError,这是组件内部私有状态,完全和测试时传入的props.setInputError无关。组件执行时只会调用自己的setInputError,根本不会用到你mock的那个函数,所以测试里的断言永远不会通过。

核心问题2:组件内部的状态更新错误

在密码输入框的onChange事件中,你调用了setInputError(false),把原本是对象类型的inputError改成了布尔值false。后续执行registerAccountHandler时,checkInputErrors返回的是对象,但此时inputError已经变成布尔值,可能导致逻辑判断异常(比如inputError.password会变成undefined,影响后续的错误提示渲染)。虽然你说组件功能正常,但这个写法存在隐患,也可能干扰测试逻辑。


正确的解决思路

方案1:遵循RTL测试原则,测试用户可见行为

React Testing Library的核心思想是测试用户能看到和交互的内容,而不是内部函数调用。你应该断言:当密码不匹配时,页面是否显示了对应的错误提示(比如输入框的错误状态、错误文本),而不是验证setInputError是否被调用。

示例:

it("should display password mismatch error when passwords do not match", async () => {
    const { getByLabelText, getByText } = render(<SignupPage t={(key) => key} />);
    const passwordInput = getByLabelText("pages.login.signup.password");
    const confirmPasswordInput = getByLabelText("pages.login.signup.confirm_password");
    const registerButton = getByText("pages.login.signup.register");
    
    fireEvent.change(passwordInput, { target: { value: "password1" } });
    fireEvent.change(confirmPasswordInput, { target: { value: "password2" } });
    fireEvent.click(registerButton);

    // 断言错误提示是否出现(假设错误文本的key是"error.password_mismatch")
    expect(getByText("error.password_mismatch")).toBeInTheDocument();
    // 或者断言输入框的错误状态
    expect(confirmPasswordInput).toHaveClass("error-input");
});

方案2:若必须验证setInputError调用,mock useState

如果你一定要验证内部状态更新,可以通过mockReact.useState来替换组件的setInputError:

it("should call setInputError with passwordMatch true when passwords do not match", async () => {
    const mockSetInputError = jest.fn();
    // 先mock组件的inputError useState
    jest.spyOn(React, 'useState')
        .mockImplementationOnce(initial => [initial, mockSetInputError])
        // 如果组件还有其他useState调用,需要依次mock,避免影响
        .mockImplementationOnce(initial => [initial, jest.fn()]) // 比如inputPassword的useState
        .mockImplementationOnce(initial => [initial, jest.fn()]); // confirmInputPassword的useState

    const { getByLabelText, getByText } = render(<SignupPage t={(key) => key} />);
    const passwordInput = getByLabelText("pages.login.signup.password");
    const confirmPasswordInput = getByLabelText("pages.login.signup.confirm_password");
    const registerButton = getByText("pages.login.signup.register");
    
    fireEvent.change(passwordInput, { target: { value: "password1" } });
    fireEvent.change(confirmPasswordInput, { target: { value: "password2" } });
    fireEvent.click(registerButton);

    expect(mockSetInputError).toHaveBeenCalledWith(expect.objectContaining({
        passwordMatch: true,
        password: false, // 假设password1符合规则
        confirmPassword: true
    }));
});

修复组件的状态更新错误

把onChange中的setInputError(false)改成重置为初始的错误对象:

onChange={(val) => {
    setInputPassword(val);
    setInputError({
        email: false,
        confirmEmail: false,
        password: false,
        confirmPassword: false,
        passwordMatch: false,
    });
}}

内容的提问来源于stack exchange,提问作者Asher

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.24 04:57:01