10. 组件测试 - React Testing Library概述React Testing Library (RTL) 是一个用于测试 React 组件的工具库它鼓励编写以用户行为为中心的测试而不是测试实现细节。通过模拟用户交互验证组件的行为是否符合预期。维度内容What以用户行为为中心测试 React 组件Why测试组件的实际行为而非实现细节When测试组件渲染、用户交互、异步行为Where组件测试文件Who需要测试组件的开发者Howrender(Component /); fireEvent.click(screen.getByText(提交))1. RTL 基础1.1 安装配置npminstall--save-dev testing-library/react testing-library/jest-dom testing-library/user-event// setupTests.js import testing-library/jest-dom; // package.json { scripts: { test: react-scripts test, test:coverage: npm test -- --coverage } }1.2 基本测试// Button.jsx export default function Button({ children, onClick, disabled }) { return ( button onClick{onClick} disabled{disabled} {children} /button ); } // Button.test.jsx import { render, screen } from testing-library/react; import userEvent from testing-library/user-event; import Button from ./Button; describe(Button, () { test(渲染按钮文本, () { render(Button点击我/Button); expect(screen.getByText(点击我)).toBeInTheDocument(); }); test(点击时调用 onClick, async () { const handleClick jest.fn(); const user userEvent.setup(); render(Button onClick{handleClick}点击/Button); await user.click(screen.getByText(点击)); expect(handleClick).toHaveBeenCalledTimes(1); }); test(disabled 时不可点击, async () { const handleClick jest.fn(); const user userEvent.setup(); render(Button onClick{handleClick} disabled点击/Button); await user.click(screen.getByText(点击)); expect(handleClick).not.toHaveBeenCalled(); }); });2. 查询方法2.1 单元素查询import { render, screen } from testing-library/react; // getBy - 找不到时抛出错误 test(getBy 示例, () { render(divHello World/div); expect(screen.getByText(Hello World)).toBeInTheDocument(); // screen.getByText(Not Exist) // 抛出错误 }); // queryBy - 找不到时返回 null test(queryBy 示例, () { render(divHello World/div); expect(screen.queryByText(Not Exist)).toBeNull(); }); // findBy - 用于异步元素 test(findBy 示例, async () { render(AsyncComponent /); const element await screen.findByText(加载完成); expect(element).toBeInTheDocument(); });2.2 多元素查询function TodoList({ todos }) { return ( ul {todos.map(todo ( li key{todo.id}{todo.text}/li ))} /ul ); } test(查询多个元素, () { const todos [ { id: 1, text: 任务1 }, { id: 2, text: 任务2 }, { id: 3, text: 任务3 }, ]; render(TodoList todos{todos} /); // getAllBy - 返回数组 const items screen.getAllByRole(listitem); expect(items).toHaveLength(3); // queryAllBy - 返回数组或空数组 expect(screen.queryAllByText(不存在)).toHaveLength(0); // findAllBy - 异步返回数组 // const items await screen.findAllByRole(listitem); });2.3 查询优先级// 优先级从高到低 // 1. 可访问性查询推荐 screen.getByRole(button, { name: /提交/i }); screen.getByLabelText(用户名); screen.getByPlaceholderText(请输入邮箱); screen.getByText(确认); screen.getByDisplayValue(初始值); // 2. 语义查询 screen.getByAltText(用户头像); screen.getByTitle(关闭); // 3. 测试 ID最后选择 screen.getByTestId(submit-button);3. 用户交互3.1 使用 userEventimport { render, screen } from testing-library/react; import userEvent from testing-library/user-event; function LoginForm({ onSubmit }) { const [email, setEmail] useState(); const [password, setPassword] useState(); const handleSubmit (e) { e.preventDefault(); onSubmit({ email, password }); }; return ( form onSubmit{handleSubmit} input typeemail placeholder邮箱 value{email} onChange{(e) setEmail(e.target.value)} / input typepassword placeholder密码 value{password} onChange{(e) setPassword(e.target.value)} / button typesubmit登录/button /form ); } test(表单提交, async () { const handleSubmit jest.fn(); const user userEvent.setup(); render(LoginForm onSubmit{handleSubmit} /); await user.type(screen.getByPlaceholderText(邮箱), testexample.com); await user.type(screen.getByPlaceholderText(密码), password123); await user.click(screen.getByText(登录)); expect(handleSubmit).toHaveBeenCalledWith({ email: testexample.com, password: password123, }); });3.2 常用交互test(用户交互示例, async () { const user userEvent.setup(); // 点击 await user.click(screen.getByText(按钮)); await user.dblClick(screen.getByText(双击)); // 输入 await user.type(screen.getByRole(textbox), Hello); await user.clear(screen.getByRole(textbox)); // 选择 await user.selectOptions( screen.getByRole(combobox), option-value ); // 复选框 await user.click(screen.getByRole(checkbox)); // 键盘 await user.keyboard({Enter}); await user.tab(); // 悬停 await user.hover(screen.getByText(悬停)); await user.unhover(screen.getByText(悬停)); });4. 异步测试4.1 waitForfunction AsyncComponent() { const [data, setData] useState(null); useEffect(() { setTimeout(() setData(加载完成), 500); }, []); return div{data || 加载中...}/div; } import { render, screen, waitFor } from testing-library/react; test(等待异步内容, async () { render(AsyncComponent /); expect(screen.getByText(加载中...)).toBeInTheDocument(); await waitFor(() { expect(screen.getByText(加载完成)).toBeInTheDocument(); }); });4.2 findBytest(使用 findBy 等待异步内容, async () { render(AsyncComponent /); const element await screen.findByText(加载完成); expect(element).toBeInTheDocument(); });4.3 异步 API 测试// 需要 Mock API jest.mock(./api); function UserProfile({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(true); useEffect(() { fetchUser(userId).then(user { setUser(user); setLoading(false); }); }, [userId]); if (loading) return div加载中.../div; return div{user?.name}/div; } test(加载用户数据, async () { const mockUser { id: 1, name: 张三 }; fetchUser.mockResolvedValue(mockUser); render(UserProfile userId{1} /); expect(screen.getByText(加载中...)).toBeInTheDocument(); const userName await screen.findByText(张三); expect(userName).toBeInTheDocument(); });5. 完整示例Todo 应用测试// TodoApp.jsx import { useState } from react; export default function TodoApp() { const [todos, setTodos] useState([]); const [input, setInput] useState(); const addTodo () { if (input.trim()) { setTodos([...todos, { id: Date.now(), text: input, completed: false }]); setInput(); } }; const toggleTodo (id) { setTodos(todos.map(todo todo.id id ? { ...todo, completed: !todo.completed } : todo )); }; const deleteTodo (id) { setTodos(todos.filter(todo todo.id ! id)); }; return ( div h1待办事项/h1 div input value{input} onChange{(e) setInput(e.target.value)} placeholder添加新任务 / button onClick{addTodo}添加/button /div ul {todos.map(todo ( li key{todo.id} span style{{ textDecoration: todo.completed ? line-through : none }} onClick{() toggleTodo(todo.id)} {todo.text} /span button onClick{() deleteTodo(todo.id)}删除/button /li ))} /ul /div ); } // TodoApp.test.jsx import { render, screen } from testing-library/react; import userEvent from testing-library/user-event; import TodoApp from ./TodoApp; describe(TodoApp, () { test(初始状态没有待办事项, () { render(TodoApp /); expect(screen.queryByRole(listitem)).not.toBeInTheDocument(); }); test(可以添加待办事项, async () { const user userEvent.setup(); render(TodoApp /); const input screen.getByPlaceholderText(添加新任务); const addButton screen.getByText(添加); await user.type(input, 学习 React 测试); await user.click(addButton); expect(screen.getByText(学习 React 测试)).toBeInTheDocument(); }); test(不能添加空白任务, async () { const user userEvent.setup(); render(TodoApp /); const addButton screen.getByText(添加); await user.click(addButton); expect(screen.queryByRole(listitem)).not.toBeInTheDocument(); }); test(可以切换待办事项状态, async () { const user userEvent.setup(); render(TodoApp /); const input screen.getByPlaceholderText(添加新任务); const addButton screen.getByText(添加); await user.type(input, 完成这个任务); await user.click(addButton); const todoText screen.getByText(完成这个任务); expect(todoText).toHaveStyle({ textDecoration: none }); await user.click(todoText); expect(todoText).toHaveStyle({ textDecoration: line-through }); }); test(可以删除待办事项, async () { const user userEvent.setup(); render(TodoApp /); const input screen.getByPlaceholderText(添加新任务); const addButton screen.getByText(添加); await user.type(input, 待删除任务); await user.click(addButton); expect(screen.getByText(待删除任务)).toBeInTheDocument(); const deleteButton screen.getByText(删除); await user.click(deleteButton); expect(screen.queryByText(待删除任务)).not.toBeInTheDocument(); }); test(可以添加多个待办事项, async () { const user userEvent.setup(); render(TodoApp /); const input screen.getByPlaceholderText(添加新任务); const addButton screen.getByText(添加); await user.type(input, 任务1); await user.click(addButton); await user.type(input, 任务2); await user.click(addButton); await user.type(input, 任务3); await user.click(addButton); const items screen.getAllByRole(listitem); expect(items).toHaveLength(3); }); });6. 总结核心要点要点说明核心原则测试用户行为而非实现细节查询方法getBy、queryBy、findBy用户交互userEvent 模拟真实操作异步处理waitFor、findBy测试优先级可访问性查询getByRole、getByLabelText语义查询getByText、getByPlaceholderText测试 IDgetByTestId记忆口诀RTL 测试用户行不测实现细节好查询优先选 role交互用 userEvent异步等待 waitFor组件行为全测到7. 相关资源React Testing Library 文档Jest DOM 匹配器User Event 文档