React 18 + RTL 测试含三元条件渲染的登录函数组件
解决React Testing Library测试LoginForm状态驱动渲染的问题
核心思路纠正
不要直接模拟useState,React Testing Library的设计核心是模拟用户行为而非直接篡改组件内部状态,直接修改状态违背了测试的真实性(测试应还原用户实际操作后的组件表现)。如果要覆盖不同状态的渲染场景,更合理的方式是:
- 将状态逻辑抽离到自定义Hook中,通过测试Hook或注入Hook实现状态控制
- 模拟触发状态变化的用户操作/异步请求(比如模拟登录接口返回成功/加载中)
具体实现方案
方案1:抽离状态到自定义Hook(推荐)
把LoginForm的状态逻辑(authSuccess、isLoading、错误信息)放到自定义Hook里,比如useLoginForm:
// useLoginForm.ts import { useState } from 'react'; import * as api from './api'; export const useLoginForm = () => { const [authSuccess, setAuthSuccess] = useState(false); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const handleLogin = async (credentials: {username: string; password: string}) => { setIsLoading(true); try { await api.login(credentials); setAuthSuccess(true); } catch (err) { setErrorMessage('登录失败,请检查账号密码'); } finally { setIsLoading(false); } }; return { authSuccess, isLoading, errorMessage, handleLogin }; };
在LoginForm中引入该Hook:
// LoginForm.tsx import { useLoginForm } from './useLoginForm'; import { Card, Spinner, Navigate, Form, Button } from './components'; export const LoginForm = () => { const { authSuccess, isLoading, errorMessage, handleLogin } = useLoginForm(); if (authSuccess) { return <Navigate to="/account" />; } return ( <Card> {isLoading ? ( <Spinner aria-label="加载中" /> ) : ( <Form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); handleLogin({ username: formData.get('username') as string, password: formData.get('password') as string, }); }}> {errorMessage && <div className="error" role="alert">{errorMessage}</div>} <label htmlFor="username">用户名</label> <input id="username" name="username" type="text" /> <label htmlFor="password">密码</label> <input id="password" name="password" type="password" /> <Button type="submit">登录</Button> </Form> )} </Card> ); };
测试时,通过mock自定义Hook的返回值来控制状态:
// LoginForm.test.tsx import { render, screen } from '@testing-library/react'; import { LoginForm } from './LoginForm'; import { useLoginForm } from './useLoginForm'; jest.mock('./useLoginForm'); test('authSuccess为true时渲染Navigate且无Card', () => { (useLoginForm as jest.Mock).mockReturnValue({ authSuccess: true, isLoading: false, errorMessage: '', handleLogin: jest.fn(), }); render(<LoginForm />); expect(screen.queryByRole('region')).not.toBeInTheDocument(); // 假设Card用region role expect(screen.getByRole('navigation')).toBeInTheDocument(); // 假设Navigate对应navigation role }); test('isLoading为true时Card内显示Spinner', () => { (useLoginForm as jest.Mock).mockReturnValue({ authSuccess: false, isLoading: true, errorMessage: '', handleLogin: jest.fn(), }); render(<LoginForm />); expect(screen.getByRole('region')).toBeInTheDocument(); expect(screen.getByLabelText('加载中')).toBeInTheDocument(); expect(screen.queryByRole('form')).not.toBeInTheDocument(); }); test('isLoading为false时Card内显示表单', () => { (useLoginForm as jest.Mock).mockReturnValue({ authSuccess: false, isLoading: false, errorMessage: '', handleLogin: jest.fn(), }); render(<LoginForm />); expect(screen.getByRole('region')).toBeInTheDocument(); expect(screen.getByRole('form')).toBeInTheDocument(); expect(screen.queryByLabelText('加载中')).not.toBeInTheDocument(); });
方案2:模拟用户操作触发状态变化(贴近真实场景)
如果不想抽离Hook,直接模拟用户提交表单的行为,通过mock登录接口控制状态变化:
// LoginForm.test.tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { LoginForm } from './LoginForm'; import * as api from './api'; jest.mock('./api'); test('提交表单后显示Spinner,成功后重定向', async () => { // 模拟登录接口延迟返回成功 (api.login as jest.Mock).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)) ); render(<LoginForm />); // 输入账号密码并提交 fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'test' } }); fireEvent.change(screen.getByLabelText('密码'), { target: { value: '123456' } }); fireEvent.submit(screen.getByRole('form')); // 检查加载状态 expect(screen.getByLabelText('加载中')).toBeInTheDocument(); expect(screen.queryByRole('form')).not.toBeInTheDocument(); // 等待登录成功,验证重定向 await waitFor(() => { expect(screen.queryByRole('region')).not.toBeInTheDocument(); expect(screen.getByRole('navigation')).toBeInTheDocument(); }); }); test('登录失败显示错误信息', async () => { // 模拟登录接口返回失败 (api.login as jest.Mock).mockRejectedValue(new Error('登录失败')); render(<LoginForm />); fireEvent.submit(screen.getByRole('form')); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('登录失败,请检查账号密码'); expect(screen.getByRole('form')).toBeInTheDocument(); expect(screen.queryByLabelText('加载中')).not.toBeInTheDocument(); }); });
为什么不直接模拟useState?
直接用jest.spyOn(React, 'useState')会全局mock所有组件的useState,容易引发测试污染;而且useState返回的是[state, setState]成对数组,模拟时需要处理复杂的返回值逻辑,维护成本极高,同时也不符合React Testing Library"测试用户行为而非实现细节"的核心原则。
内容的提问来源于stack exchange,提问作者Bryan Jastrzembski
相关产品推荐
相关产品推荐

