React Testing Library中正确mock setState及调用次数为0问题排查
我正尝试使用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

