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

React 18 + RTL 测试含三元条件渲染的登录函数组件

解决React Testing Library测试LoginForm状态驱动渲染的问题

核心思路纠正

不要直接模拟useState,React Testing Library的设计核心是模拟用户行为而非直接篡改组件内部状态,直接修改状态违背了测试的真实性(测试应还原用户实际操作后的组件表现)。如果要覆盖不同状态的渲染场景,更合理的方式是:

  1. 将状态逻辑抽离到自定义Hook中,通过测试Hook或注入Hook实现状态控制
  2. 模拟触发状态变化的用户操作/异步请求(比如模拟登录接口返回成功/加载中)

具体实现方案

方案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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 23:20:34