第19期 | 测试入门:Jest与React Testing Library——让你大胆重构、放心发布
第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 的核心理念:
你的测试越像用户使用软件的方式,越能给你信心。
这意味着:
- ✅ 通过文本内容、角色(按钮、标题、输入框)来查找元素
- ❌ 不要通过
className、id、组件内部 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% 需要你结合业务场景补充。
💻 动手练习
简单: 为 truncate 和 slugify 函数写完整的单元测试,每个函数至少 4 个测试用例。
中等: 测试一个 SearchInput 组件:
- 渲染正确(有输入框和清空按钮区域)
- 输入文字后,
onChange回调被正确调用 - 输入文字后,清空按钮出现;点击清空按钮,输入框变空且
onChange被调用空字符串
挑战: 测试第18期实战项目中的 Zustand Store:
addTask后,tasks.length增加 1deleteTask后,对应 id 的任务不存在changeStatus为 ‘done’ 时,completedAt字段被设置getStats返回正确的统计数据
📌 本期要点
- 测试是安全网,让你大胆重构:有测试 = 有信心;没测试 = 每次改代码都在祈祷
- RTL 测试行为,不测实现:用户怎么用,就怎么测,不要测 state 内部值
- 查询优先级:getByRole > getByLabelText > getByText > getByTestId
vi.fn()是 Mock 大杀器:mock 外部依赖(API、回调函数),隔离被测代码- AI 能生成 80% 的测试,剩下 20% 的边界情况需要你的业务理解来补充
🔗 下期预告
第20期:模块二复盘 + Vue 3 快速入门——6 个月后市场上同时存在 React 和 Vue 岗位,了解 Vue 让你的求职更灵活,而且学过 React 再看 Vue 会豁然开朗。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交
更多推荐
所有评论(0)