第19期 | 测试入门:Jest与React Testing Library——让你大胆重构、放心发布

🎯 今天你将学会

  • 理解为什么写测试(不是应付面试,是真的有用)
  • 用 Jest 写单元测试:测试工具函数、自定义 Hook
  • 用 React Testing Library 测试组件:正确的测试思路
  • 知道什么该测,什么不必测
  • 用 AI 批量生成测试用例并审查

📖 核心知识

1. 为什么要写测试:三个真实场景

场景1:大胆重构
你写了一个 formatPrice 函数,用在 50 个组件里。现在要重构它的逻辑。有测试 → 改完运行一下,所有用例全绿,放心发布。没测试 → 不敢改,或者改完焦虑地手动点每个页面。

场景2:放心接手遗留代码
接手别人的代码,有测试 → 看测试就能理解业务逻辑,改了什么地方测试会告诉你。没测试 → 只能小心翼翼,改一行祈祷没副作用。

场景3:CI/CD 自动检查
代码合并到主分支前自动跑测试,有问题阻止合并。这是工业级项目的标准流程。

测试不是银弹,但是: 前端项目的核心业务逻辑、工具函数、关键组件 → 值得投入时间写测试。样式调整、UI 细节 → 不必测试。


2. Jest 基础:测试工具函数

安装(Vite 项目):

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

vite.config.ts 中启用测试:

export default defineConfig({
  // ...
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
});

src/test/setup.ts

import '@testing-library/jest-dom';

package.json 脚本:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "coverage": "vitest run --coverage"
  }
}

第一个测试:测工具函数

// src/utils/format.ts
export function formatPrice(price: number, currency = 'CNY'): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency,
  }).format(price);
}

export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength) + '...';
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]+/g, '');
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatPrice, truncate, slugify } from './format';

describe('formatPrice', () => {
  it('正常格式化人民币价格', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50');
  });

  it('格式化美元', () => {
    expect(formatPrice(99.99, 'USD')).toBe('US$99.99');
  });

  it('处理整数价格', () => {
    expect(formatPrice(100)).toBe('¥100.00');
  });
});

describe('truncate', () => {
  it('文本短于 maxLength 时不截断', () => {
    expect(truncate('Hello', 10)).toBe('Hello');
  });

  it('文本等于 maxLength 时不截断', () => {
    expect(truncate('Hello', 5)).toBe('Hello');
  });

  it('文本长于 maxLength 时截断并加省略号', () => {
    expect(truncate('Hello World', 5)).toBe('Hello...');
  });

  it('处理空字符串', () => {
    expect(truncate('', 5)).toBe('');
  });
});

describe('slugify', () => {
  it('转换空格为连字符', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('移除特殊字符', () => {
    expect(slugify('React & TypeScript!')).toBe('react--typescript');
  });
});

3. 测试自定义 Hook

// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0, options?: { min?: number; max?: number }) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(prev => {
      if (options?.max !== undefined && prev >= options.max) return prev;
      return prev + 1;
    });
  }, [options?.max]);

  const decrement = useCallback(() => {
    setCount(prev => {
      if (options?.min !== undefined && prev <= options.min) return prev;
      return prev - 1;
    });
  }, [options?.min]);

  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('初始值默认为 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('使用指定初始值', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increment 增加计数', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(6);
  });

  it('decrement 减少计数', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.decrement();
    });
    expect(result.current.count).toBe(4);
  });

  it('reset 重置为初始值', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    expect(result.current.count).toBe(5);
  });

  it('不超过 max 值', () => {
    const { result } = renderHook(() => useCounter(9, { max: 10 }));
    act(() => {
      result.current.increment();
      result.current.increment(); // 第二次应该不生效
    });
    expect(result.current.count).toBe(10);
  });

  it('不低于 min 值', () => {
    const { result } = renderHook(() => useCounter(1, { min: 0 }));
    act(() => {
      result.current.decrement();
      result.current.decrement(); // 第二次应该不生效
    });
    expect(result.current.count).toBe(0);
  });
});

4. React Testing Library:测试组件

RTL 的核心理念:

你的测试越像用户使用软件的方式,越能给你信心。

这意味着:

  • ✅ 通过文本内容角色(按钮、标题、输入框)来查找元素
  • ❌ 不要通过 classNameid、组件内部 state 来测试
// src/components/LoginForm/LoginForm.tsx
import { useState } from 'react';

interface LoginFormProps {
  onSubmit: (credentials: { email: string; password: string }) => Promise<void>;
}

function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email || !password) {
      setError('请填写所有字段');
      return;
    }
    try {
      setIsLoading(true);
      setError('');
      await onSubmit({ email, password });
    } catch (err) {
      setError(err instanceof Error ? err.message : '登录失败');
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <h1>登录</h1>
      {error && <p role="alert">{error}</p>}
      <label>
        邮箱
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          placeholder="请输入邮箱"
        />
      </label>
      <label>
        密码
        <input
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          placeholder="请输入密码"
        />
      </label>
      <button type="submit" disabled={isLoading}>
        {isLoading ? '登录中...' : '登录'}
      </button>
    </form>
  );
}
// src/components/LoginForm/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import LoginForm from './LoginForm';

// 每个测试前重置
beforeEach(() => {
  vi.clearAllMocks();
});

describe('LoginForm', () => {
  it('渲染登录表单', () => {
    render(<LoginForm onSubmit={vi.fn()} />);
    
    expect(screen.getByRole('heading', { name: '登录' })).toBeInTheDocument();
    expect(screen.getByPlaceholderText('请输入邮箱')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
  });

  it('提交空表单时显示错误', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);
    
    await user.click(screen.getByRole('button', { name: '登录' }));
    
    expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段');
  });

  it('填写正确信息后调用 onSubmit', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockResolvedValue(undefined); // mock 异步函数
    render(<LoginForm onSubmit={mockSubmit} />);
    
    await user.type(screen.getByPlaceholderText('请输入邮箱'), 'test@example.com');
    await user.type(screen.getByPlaceholderText('请输入密码'), 'password123');
    await user.click(screen.getByRole('button', { name: '登录' }));
    
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });

  it('提交时按钮变为"登录中..."并禁用', async () => {
    const user = userEvent.setup();
    // 创建一个永不 resolve 的 Promise 来模拟加载状态
    const mockSubmit = vi.fn().mockReturnValue(new Promise(() => {}));
    render(<LoginForm onSubmit={mockSubmit} />);
    
    await user.type(screen.getByPlaceholderText('请输入邮箱'), 'test@example.com');
    await user.type(screen.getByPlaceholderText('请输入密码'), 'pass');
    await user.click(screen.getByRole('button'));
    
    expect(screen.getByRole('button')).toHaveTextContent('登录中...');
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('onSubmit 抛出错误时显示错误信息', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockRejectedValue(new Error('账号或密码错误'));
    render(<LoginForm onSubmit={mockSubmit} />);
    
    await user.type(screen.getByPlaceholderText('请输入邮箱'), 'test@example.com');
    await user.type(screen.getByPlaceholderText('请输入密码'), 'wrong');
    await user.click(screen.getByRole('button', { name: '登录' }));
    
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('账号或密码错误');
    });
  });
});

5. 查询元素的优先级

RTL 提供多种查询方式,按推荐程度排序:

优先级 方法 适用场景
1(最优) getByRole 按 ARIA role 查询(button/heading/textbox 等)
2 getByLabelText 表单元素(配合 <label> 使用)
3 getByPlaceholderText 有 placeholder 的输入框
4 getByText 按文本内容查找
5 getByDisplayValue 有当前值的输入框
6 getByAltText 图片(按 alt)
7 getByTitle 有 title 属性的元素
8(最后) getByTestId data-testid 属性(尽量避免)

getBy vs queryBy vs findBy

  • getBy:找不到就报错(同步)
  • queryBy:找不到返回 null(用于"确认元素不存在")
  • findBy:返回 Promise,等待异步出现的元素

6. 什么该测,什么不必测

值得测试:

  • ✅ 工具函数和业务逻辑(formatPrice、validate、calculate)
  • ✅ 自定义 Hook(特别是有复杂状态逻辑的)
  • ✅ 关键组件的关键行为(表单提交、列表渲染、条件显示)
  • ✅ 容易出错的边界情况(空列表、null 值、异步失败)

不必测试:

  • ❌ CSS 样式和视觉细节(截图测试是另一种方式)
  • ❌ 第三方库的行为(信任它们有自己的测试)
  • ❌ 实现细节(state 的内部值、私有函数)
  • ❌ 简单的展示组件(只是 props 渲染)

🤖 AI协作实战:AI 批量生成测试用例

我给 AI 的 Prompt:

这是我写的 validateEmail 和 validatePassword 函数,
请帮我写完整的 Vitest 单元测试,要求覆盖正常情况、边界情况和错误情况:

function validateEmail(email: string): { valid: boolean; message: string } {
  if (!email) return { valid: false, message: '邮箱不能为空' };
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) return { valid: false, message: '邮箱格式不正确' };
  return { valid: true, message: '' };
}

function validatePassword(password: string): { valid: boolean; message: string } {
  if (!password) return { valid: false, message: '密码不能为空' };
  if (password.length < 8) return { valid: false, message: '密码长度不能少于8位' };
  if (!/[A-Z]/.test(password)) return { valid: false, message: '密码需包含大写字母' };
  if (!/[0-9]/.test(password)) return { valid: false, message: '密码需包含数字' };
  return { valid: true, message: '' };
}

AI 生成了 16 个测试用例,覆盖了:

  • 空字符串
  • 缺少 @ 符号、缺少域名后缀
  • 有效的各种邮箱格式
  • 密码太短(7位、8位边界)
  • 缺大写字母、缺数字
  • 同时缺两个条件(按顺序只报第一个错)

我发现 AI 遗漏的情况:

  • 邮箱里有空格(user name@example.com)—— 正则应该阻止但值得有用例
  • 超长密码(测试上限有没有?)

这说明 AI 能生成 80% 的测试,剩下 20% 需要你结合业务场景补充。


💻 动手练习

简单:truncateslugify 函数写完整的单元测试,每个函数至少 4 个测试用例。

中等: 测试一个 SearchInput 组件:

  • 渲染正确(有输入框和清空按钮区域)
  • 输入文字后,onChange 回调被正确调用
  • 输入文字后,清空按钮出现;点击清空按钮,输入框变空且 onChange 被调用空字符串

挑战: 测试第18期实战项目中的 Zustand Store:

  • addTask 后,tasks.length 增加 1
  • deleteTask 后,对应 id 的任务不存在
  • changeStatus 为 ‘done’ 时,completedAt 字段被设置
  • getStats 返回正确的统计数据

📌 本期要点

  1. 测试是安全网,让你大胆重构:有测试 = 有信心;没测试 = 每次改代码都在祈祷
  2. RTL 测试行为,不测实现:用户怎么用,就怎么测,不要测 state 内部值
  3. 查询优先级:getByRole > getByLabelText > getByText > getByTestId
  4. vi.fn() 是 Mock 大杀器:mock 外部依赖(API、回调函数),隔离被测代码
  5. AI 能生成 80% 的测试,剩下 20% 的边界情况需要你的业务理解来补充

🔗 下期预告

第20期:模块二复盘 + Vue 3 快速入门——6 个月后市场上同时存在 React 和 Vue 岗位,了解 Vue 让你的求职更灵活,而且学过 React 再看 Vue 会豁然开朗。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交

更多推荐