React测试实战:用RTL构建用户行为契约而非实现快照
1. 这不是“写个测试”而已:React应用测试的真实战场
你打开一个刚用create-react-app搭好的项目,src/App.test.js里那行expect(screen.getByText(/learn react/i)).toBeInTheDocument();像句吉祥话——它确实能跑通,但真以为这就叫“会测React”?我带过十几支前端团队,看过上百份简历,发现一个扎心事实:90%标榜“熟悉Jest+RTL”的人,连组件挂载后状态更新的异步时机都搞不清,更别说模拟真实用户交互链路了。这不是能力问题,是没人告诉你测试的本质不是“让绿条变长”,而是用代码构建一套可验证的用户行为契约。React Testing Library(RTL)的官网第一句话就写着:“Render components, make assertions, fire events, assert results — the way a user would.” 翻译过来就是:你得像用户那样点、输、滚动、等待,而不是像开发者那样去窥探state或props。这直接决定了你写的测试是“活的契约”还是“死的装饰”。Jest不是万能胶水,它只是提供沙盒环境和断言基础;RTL也不是魔法,它本质是一套基于DOM查询的、反模式的测试哲学——它故意不让你访问内部实现,逼你只关注用户可见行为。所以当你看到“react面试题”里高频出现“如何测试useEffect里的API调用”,或者“react antd table rowselection 卡顿”这种性能相关问题时,真正要考的从来不是API用法,而是你能否设计出能暴露这类问题的测试场景。这篇文章不讲“怎么配Jest”,不列RTL所有query方法,而是带你从零开始,亲手搭建一个能真实拦截“点击按钮后列表没刷新”、“搜索框输入中文后无响应”、“表单提交失败但错误提示不显示”这类线上高频Bug的测试体系。你会看到,一个fireEvent.click()背后藏着微任务队列的执行顺序,一个await waitFor(() => expect(...))实际在轮询DOM变化,而act()函数根本不是可选项——它是React 18并发渲染下避免警告的唯一安全阀。如果你正被“react fetch提示 you need to enable javascript to run this app.”这类环境问题困扰,或纠结于“react全局变量的方案”带来的测试污染,那么接下来的内容,就是你跳过所有弯路的实操地图。
2. 核心设计逻辑:为什么必须放弃“测试实现细节”的老路
2.1 RTL的底层哲学:从“测试组件内部”到“测试用户行为”
十年前,我们写React测试,第一反应是shallow render,然后expect(wrapper.state().loading).toBe(true)。现在回头看,这就像给汽车引擎盖上贴张纸条说“这台发动机转速5000rpm”,却从不检查车能不能开、刹车灵不灵。RTL彻底颠覆了这个思路。它的核心原则是**“asymmetric testing”**(非对称测试):测试代码永远站在用户视角,而被测代码永远站在实现视角,两者之间不该有耦合。这意味着什么?意味着你永远不该写这样的测试:
// ❌ 错误示范:测试实现细节,脆弱且无业务价值 test('renders loading state', () => { const wrapper = mount(<UserProfile userId="123" />); // 假设组件内部用了一个叫 'isLoading' 的state expect(wrapper.state().isLoading).toBe(true); });这段代码的问题在于:一旦开发把isLoading重命名为isFetching,或者改用useReducer管理状态,测试立刻崩,但功能完全没变。用户根本不在乎变量名,只在乎“头像没出来时,页面是否显示一个旋转图标”。RTL强制你写成这样:
// ✅ 正确示范:测试用户可见结果 test('shows loading spinner while fetching user data', async () => { // 模拟API返回延迟 jest.mock('./api/userApi', () => ({ fetchUser: jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ name: 'John' }), 100)) ) })); render(<UserProfile userId="123" />); // 用户看到什么?一个role为'status'的元素,文本包含'loading' expect(screen.getByRole('status')).toHaveTextContent(/loading/i); // 等待API完成,用户看到新内容 await waitFor(() => expect(screen.getByText('John')).toBeInTheDocument()); });这里的关键转变是:查询目标从内部状态(state.isLoading)变成了可访问性语义(getByRole('status'))和用户语言(/loading/i)。这直接关联到“react中的await”和“react useeffect 源码解析”这些热词——因为useEffect触发的副作用(如API调用)必然导致DOM变化,而RTL的waitFor正是为捕获这种异步DOM更新而生。它内部不是简单setTimeout,而是利用MutationObserver监听DOM变动,并配合Jest的Fake Timers精确控制时间流。我见过太多人卡在“为什么await waitFor不生效”,根源就是没理解它监听的是DOM变更事件,而不是Promise状态。
2.2 Jest的角色再定位:不只是断言,更是可控的时空机器
很多人把Jest当成“高级console.log”,其实它最强大的能力是时空操控。在React测试中,Jest的三大核心武器是:Fake Timers、Mock Functions、以及隔离的Test Environment。先看Fake Timers。假设你的组件里有这样一个逻辑:
function AutoSaveInput({ onSave }) { const [value, setValue] = useState(''); useEffect(() => { const timer = setTimeout(() => { if (value.trim()) onSave(value); }, 2000); // 2秒后自动保存 return () => clearTimeout(timer); }, [value, onSave]); return <input value={value} onChange={e => setValue(e.target.value)} />; }要测试“输入后2秒触发保存”,传统方式得等2秒,测试套件慢如蜗牛。Jest的Fake Timers让你把2秒压缩成0毫秒:
test('saves input after 2 seconds of inactivity', () => { const mockOnSave = jest.fn(); render(<AutoSaveInput onSave={mockOnSave} />); const input = screen.getByRole('textbox'); fireEvent.change(input, { target: { value: 'hello' } }); // 快进2秒,触发定时器 jest.advanceTimersByTime(2000); expect(mockOnSave).toHaveBeenCalledWith('hello'); });这里jest.advanceTimersByTime(2000)不是“跳过等待”,而是重写了JavaScript的setTimeout全局行为,让所有定时器立即执行。这直接解决了“react 18 新特性”中提到的并发渲染对测试的影响——因为Fake Timers确保了时间线的确定性。再看Mock Functions。网络热词里反复出现“react fetch提示 you need to enable javascript to run this app.”,这其实是开发环境配置问题,但测试中更常见的是“fetch未定义”错误。Jest的jest.mock()能精准劫持模块:
// ✅ 正确mock fetch,避免环境依赖 global.fetch = jest.fn(); test('displays error when API fails', async () => { fetch.mockRejectedValueOnce(new Error('Network Error')); render(<DataList />); await waitFor(() => expect(screen.getByText(/error/i)).toBeInTheDocument()); });注意mockRejectedValueOnce——它只影响下一次调用,保证测试间隔离。这比手动global.fetch = jest.fn()更安全,因为后者会影响所有测试。最后是Test Environment。create-react-app默认用jsdom,它在Node.js里模拟了一个完整的浏览器DOM。这意味着你能用screen.getByText、fireEvent.click,就像在真实浏览器里一样。但jsdom不是万能的,它不支持WebGL、部分Canvas API,甚至window.matchMedia需要手动mock。这就是为什么有些人在“如何把react项目发布到宝塔上”后,测试突然报错——因为生产环境是真实浏览器,而测试环境是jsdom,二者行为有细微差异。解决方案不是换环境,而是在测试中主动适配:比如对matchMedia做如下mock:
Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: jest.fn(), // deprecated removeListener: jest.fn(), // deprecated addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), })), });这个小技巧,能帮你绕过90%的jsdom兼容性坑,也是我在“前端react 好用的架构项目”中必加的初始化脚本。
2.3 测试策略分层:单元、集成、E2E不是选择题,而是流水线
网络热词里“react bits”和“react教程”常把测试讲成孤立技能,但真实项目里,测试是分层的流水线。我把它拆成三层,每层解决不同问题:
| 层级 | 目标 | 覆盖范围 | 典型工具 | 为什么必须存在 |
|---|---|---|---|---|
| 单元测试(Unit) | 验证单个函数/自定义Hook逻辑正确 | 1个函数、1个Hook | Jest +@testing-library/react-hooks | 快(毫秒级)、准(隔离性强),是CI的第一道防线。比如测试useFormValidation是否正确处理空值。 |
| 组件集成测试(Integration) | 验证组件与子组件、Hooks、Context协同工作 | 1个组件及其直接依赖 | Jest + RTL | 揭露“组合错误”,如react antd table rowselection 卡顿——单独Table没问题,但和RowSelection一起用就卡,只有集成测试能抓到。 |
| 端到端测试(E2E) | 验证真实用户流程(登录→搜索→下单) | 跨多个页面/组件 | Cypress / Playwright | 发现环境、网络、路由等系统级问题,如“react fetch提示 you need to enable javascript to run this app.”这类部署后才暴露的错误。 |
关键认知是:这三层不是互斥的,而是递进的漏斗。单元测试快但视野窄,E2E视野宽但慢且脆。我的经验是:70%的测试用组件集成(RTL),20%用单元(纯函数/Hook),10%用E2E。比如“react全局变量的方案”,如果用context实现,单元测试只需测Provider的value是否正确传递;但集成测试必须验证Consumer组件能否实时响应context变化——这正是act()函数大显身手的地方。再比如“react router守卫”,单元测试可以验证守卫函数逻辑,但集成测试必须render(<Router><ProtectedRoute /></Router>)并模拟路由跳转,看未登录时是否重定向到登录页。这种分层思维,直接决定了你能否应对“前端react面试考察代码”中的复杂场景题。
3. 实操全流程:从零搭建可落地的测试体系
3.1 环境初始化:避开create-react-app的隐藏陷阱
create-react-app(CRA)开箱即用,但它的测试配置是“够用就好”,而非“生产就绪”。我遇到最多的问题是:测试运行时,控制台疯狂报Warning: An update inside a test was not wrapped in act(...)。这在React 18中尤其频繁,根源是CRA的Jest配置未启用--detectOpenHandles和--forceExit,导致异步操作残留。解决方案不是升级CRA,而是手动优化jest.config.js:
// jest.config.js module.exports = { // 继承CRA默认配置 ...require('react-scripts/config/jest/config.js'), // 关键优化项 testEnvironment: 'jsdom', setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'], testMatch: [ '<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}', '<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}', ], // 解决act警告的核心配置 collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/index.js', '!src/reportWebVitals.js', '!src/setupTests.js', ], // 强制清理,避免内存泄漏 detectOpenHandles: true, forceExit: true, // 提升大型测试套件稳定性 maxWorkers: '50%', };其中detectOpenHandles: true会检测未关闭的定时器、WebSocket连接等,forceExit: true确保测试进程强制退出。这两个配置能解决80%的CI环境超时问题。接着是setupTests.js,这是你注入全局mock的入口:
// src/setupTests.js import '@testing-library/jest-dom'; import { configure } from '@testing-library/react'; // 配置RTL,禁用烦人的警告(仅开发时) configure({ testIdAttribute: 'data-testid' }); // 全局mock fetch,避免网络请求 global.fetch = jest.fn(); // 全局mock console.error,防止测试因warning失败 const originalError = console.error; beforeAll(() => { console.error = (...args) => { if (/Warning.*act/.test(args[0])) return; // 忽略act警告 originalError(...args); }; }); afterAll(() => { console.error = originalError; }); // mock matchMedia(适配antd等UI库的响应式) Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ matches: query.includes('min-width') ? window.innerWidth >= 768 : false, media: query, onchange: null, addListener: jest.fn(), removeListener: jest.fn(), })), });这个文件看似简单,却是整个测试体系的基石。它解决了“react developer tools 下载”后常见的控制台干扰,也规避了“react vite csp report-uri 配置”可能引发的fetch拦截问题。特别注意testIdAttribute: 'data-testid'——这是RTL推荐的查询方式,比>// src/components/SearchBar.jsx import { useState, useEffect } from 'react'; import { searchProducts } from '../api/productApi'; export default function SearchBar({ onResults }) { const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!query.trim()) return; const controller = new AbortController(); const doSearch = async () => { try { setLoading(true); setError(null); // 注意:中文参数需encodeURIComponent const results = await searchProducts(encodeURIComponent(query)); onResults(results); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { setLoading(false); } }; doSearch(); return () => controller.abort(); }, [query, onResults]); return ( <div> <input >// src/components/SearchBar.test.jsx import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import SearchBar from './SearchBar'; import { searchProducts } from '../api/productApi'; // Mock API模块,确保测试不发真实请求 jest.mock('../api/productApi'); test('searches products and displays results', async () => { // Arrange:准备测试数据和mock const mockResults = [{ id: 1, name: 'iPhone 15' }]; searchProducts.mockResolvedValueOnce(mockResults); // Act:渲染组件 render(<SearchBar onResults={jest.fn()} />); // Assert:验证初始状态 expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); expect(screen.queryByTestId('error-message')).not.toBeInTheDocument(); });
这里jest.mock()必须放在test块外,否则mock不生效。queryByTestId是RTL的安全查询方法,当元素不存在时不抛错,适合验证“不应该出现”。
第二步:交互(Interaction)—— 模拟用户真实操作
// 继续上面的test块 // Act:模拟用户输入并提交 const input = screen.getByTestId('search-input'); fireEvent.change(input, { target: { value: '手机' } }); // 中文关键词! // 注意:fireEvent.change不会自动触发useEffect,需等待 await waitFor(() => { expect(searchProducts).toHaveBeenCalledWith('手机'); // 未编码!会出错 });这里暴露了“react get请求中文参数乱码”的典型场景:searchProducts函数内部若没做encodeURIComponent,传入的中文'手机'会导致URL编码错误。测试立刻捕获这个问题。
第三步:断言(Assertion)—— 验证用户可见结果
// 继续 // Act:让mock API返回结果 searchProducts.mockResolvedValueOnce([{ id: 1, name: '华为Mate 60' }]); // Assert:验证加载状态和结果 expect(screen.getByTestId('loading-indicator')).toHaveTextContent('搜索中...'); await waitFor(() => { expect(screen.getByText('华为Mate 60')).toBeInTheDocument(); });关键点:await waitFor必须包裹expect,因为searchProducts是异步的,DOM更新在微任务队列中。getByText比getByTestId更符合RTL哲学——它测试用户看到的文本,而非开发者打的标记。
第四步:边界(Edge Cases)—— 覆盖错误和空状态
// 新增test块 test('displays error message when API fails', async () => { // Arrange searchProducts.mockRejectedValueOnce(new Error('Network timeout')); render(<SearchBar onResults={jest.fn()} />); // Act const input = screen.getByTestId('search-input'); fireEvent.change(input, { target: { value: '耳机' } }); // Assert await waitFor(() => { expect(screen.getByTestId('error-message')).toHaveTextContent('Network timeout'); }); }); test('does not search when input is empty', () => { // Arrange render(<SearchBar onResults={jest.fn()} />); // Act const input = screen.getByTestId('search-input'); fireEvent.change(input, { target: { value: '' } }); // Assert:API未被调用 expect(searchProducts).not.toHaveBeenCalled(); });这四步法的核心是以终为始:先想用户成功/失败时看到什么,再倒推需要哪些交互和断言。它天然规避了“react hooks”滥用导致的测试难题——比如useEffect里没加依赖数组,测试会立刻暴露searchProducts被重复调用。
3.3 高级技巧实战:破解React 18并发与性能测试难题
React 18的并发渲染(Concurrent Rendering)让测试更复杂,但也提供了更强的验证能力。热词“react 18 新特性”中提到的startTransition,其测试逻辑完全不同:
// src/components/ExpensiveList.jsx import { useState, startTransition } from 'react'; export default function ExpensiveList() { const [items, setItems] = useState([]); const [isPending, setIsPending] = useState(false); const handleAdd = () => { setIsPending(true); startTransition(() => { // 模拟耗时计算 const newItems = Array.from({ length: 10000 }, (_, i) => `Item ${i}`); setItems(prev => [...prev, ...newItems]); setIsPending(false); }); }; return ( <div> <button onClick={handleAdd} disabled={isPending}> {isPending ? '添加中...' : '添加10000项'} </button> <div>test('startTransition keeps UI responsive during expensive operation', async () => { render(<ExpensiveList />); const button = screen.getByRole('button', { name: /添加/i }); const itemCount = screen.getByTestId('item-count'); // Act:点击按钮 fireEvent.click(button); // Assert:按钮立即变为pending状态 expect(button).toBeDisabled(); expect(button).toHaveTextContent('添加中...'); // Assert:itemCount立即更新为"共0项"(初始值),而非卡住 expect(itemCount).toHaveTextContent('共0项'); // 等待transition完成 await waitFor(() => { expect(itemCount).toHaveTextContent('共10000项'); }, { timeout: 5000 }); // 给足5秒,避免CI环境慢导致失败 });这里waitFor的timeout参数至关重要——它防止测试无限等待。而act()的使用场景更明确:当测试中直接调用组件内部函数(非用户交互)时,必须包裹act。例如测试自定义Hook:
// src/hooks/useCounter.js import { useState, useCallback } from 'react'; export function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = useCallback(() => setCount(c => c + 1), []); const decrement = useCallback(() => setCount(c => c - 1), []); return { count, increment, decrement }; } // src/hooks/useCounter.test.js import { renderHook, act } from '@testing-library/react-hooks'; import { useCounter } from './useCounter'; test('increment and decrement work correctly', () => { const { result } = renderHook(() => useCounter(5)); // Act:直接调用hook返回的函数,必须用act包裹 act(() => { result.current.increment(); }); expect(result.current.count).toBe(6); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(5); });没有act,React会警告“An update inside a test was not wrapped in act(...)”,因为setCount触发了状态更新,而Jest需要知道这是测试驱动的更新。这个细节,正是“react面试题”中区分初级和中级工程师的关键。
4. 常见问题与避坑指南:那些文档里不会写的血泪教训
4.1 “act警告”泛滥:不是bug,是你的测试在报警
Warning: An update inside a test was not wrapped in act(...)是React测试中最常见的警告,但它绝不是可以忽略的噪音。我的经验是:每一条act警告,都对应一个真实的用户体验缺陷。比如下面这个经典案例:
// ❌ 导致act警告的错误写法 test('updates count after button click', () => { render(<Counter />); const button = screen.getByRole('button'); fireEvent.click(button); // ❌ 错误:直接断言,未等待状态更新 expect(screen.getByText('Count: 1')).toBeInTheDocument(); });为什么报错?因为fireEvent.click触发的setState是异步的,DOM还没更新,你就去查元素了。正确解法是:
// ✅ 正确:用waitFor等待DOM更新 test('updates count after button click', async () => { render(<Counter />); const button = screen.getByRole('button'); fireEvent.click(button); await waitFor(() => { expect(screen.getByText('Count: 1')).toBeInTheDocument(); }); });但更深层的原因是:你测试的时机错了。waitFor内部会不断轮询,直到断言通过或超时。如果超时,说明组件根本没更新——这恰恰暴露了useEffect依赖数组遗漏、setState被条件阻止等真实Bug。我曾帮一个团队排查“react antd table rowselection 卡顿”,最终发现是rowSelection的onChange回调里有个未处理的Promise拒绝,导致React渲染被阻塞。这个Bug在线上静默存在,却在测试中因act警告被揪出。
4.2 中文/特殊字符处理:URL编码与DOM查询的双重陷阱
网络热词“react get请求中文参数乱码”直指一个痛点:前端发送中文参数时,后端收不到或乱码。测试中这表现为searchProducts('手机')被调用,但API mock里收到的是%E6%89%8B%E6%9C%BA(UTF-8编码)。问题不在测试,而在实现。RTL测试能帮你提前发现:
// 在API调用处加日志 export async function searchProducts(query) { console.log('Raw query:', query); // 打印原始参数 const encoded = encodeURIComponent(query); console.log('Encoded query:', encoded); // 打印编码后参数 const res = await fetch(`/api/search?q=${encoded}`); return res.json(); }测试时,console.log会输出到Jest控制台,一眼看出是否编码。另一个陷阱是DOM查询:screen.getByText('手机')可能失败,因为RTL默认按textContent匹配,而中文字符的Unicode规范化可能有差异。解决方案是用正则表达式:
// ✅ 更鲁棒的中文匹配 expect(screen.getByText(/手机/i)).toBeInTheDocument(); // 或者用toContainElement配合正则 expect(screen.queryByText(/手机/i)).toBeInTheDocument();/手机/i的i标志表示忽略大小写,对中文虽无意义,但能统一风格。更重要的是,永远不要在测试中依赖具体中文文本,而应测试其语义。比如搜索结果页,测试screen.getByRole('heading', { level: 2 })是否包含“搜索结果”,而不是硬编码“手机”。
4.3 性能测试盲区:如何用RTL量化“卡顿”
“react antd table rowselection 卡顿”这类问题,传统单元测试无法捕捉,但RTL结合Jest的性能API可以:
test('table row selection does not cause jank', async () => { // Arrange:渲染大量数据 const largeData = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}` })); render(<LargeTable data={largeData} />); // Act:测量选择一行的时间 const startTime = performance.now(); const checkbox = screen.getAllByRole('checkbox')[0]; fireEvent.click(checkbox); const endTime = performance.now(); // Assert:确保在16ms内(60fps) const duration = endTime - startTime; expect(duration).toBeLessThan(16); // 严格模式 });performance.now()提供高精度时间戳。虽然Jest测试环境的performance可能不如浏览器精确,但它足以暴露数量级问题——如果duration是100ms,说明肯定有同步阻塞操作。这个技巧,是我应对“react面试题”中性能优化类问题的杀手锏。
4.4 CI/CD集成:让测试成为上线前的铁闸
最后,测试的价值在CI中爆发。我的标准CI配置(GitHub Actions)如下:
# .github/workflows/test.yml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm ci - name: Run tests with coverage run: npm test -- --coverage --ci --silent - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }}关键参数--ci --silent让Jest在CI中安静运行,--coverage生成覆盖率报告。但覆盖率数字本身没意义,关键看哪些业务逻辑没被覆盖。我要求所有src/pages/和src/components/下的文件,测试覆盖率不低于80%,而src/api/和src/hooks/必须100%。这个规则,直接推动团队写出更易测试的代码——比如把复杂逻辑抽离到纯函数,而不是全塞在组件里。
提示:在
package.json中添加"test:watch": "react-scripts test --watch",开发时用npm run test:watch启动监听模式,改代码后测试自动重跑,效率提升3倍。
注意:永远不要在测试中使用
setTimeout或setInterval,它们会破坏Jest的Fake Timers。用jest.advanceTimersByTime()替代。
实操心得:当测试失败时,第一反应不是改代码,而是运行
npm test -- --debug,它会输出详细的堆栈和当前DOM快照,90%的问题能当场定位。
5. 最后的实战建议:从今天开始重构你的测试习惯
我在“react flow”和“react yoga”这类抽象概念上花过太多时间,直到某次线上事故让我顿悟:测试不是为了满足流程,而是为了建立确定性。那个导致“react fetch提示 you need to enable javascript to run this app.”的部署错误,根源是开发环境启用了react-app-rewired修改了webpack配置,但测试环境没同步,导致fetchpolyfill缺失。如果当时有E2E测试跑在真实浏览器里,这个错误会在合并前就被拦截。所以我的建议很直接:从明天起,给每个新功能写测试时,先问自己三个问题:
- 用户会做什么?(不是“代码会执行什么”,而是“用户会点哪里、输什么、等多久”)
- 用户会看到什么?(不是“state变成什么”,而是“屏幕上出现哪个文字、图标、动画”)
- 用户会遇到什么意外?(网络失败、输入非法、权限不足——这些边界case比Happy Path更有价值)
比如“react全局变量的方案”,如果用context,测试重点就不是Provider怎么写,而是验证Consumer组件能否在Provider更新后立即重新渲染。这需要act()和waitFor的精准配合。再比如“react rtk configurestore”,测试Store不是去测Redux Toolkit,而是测你的createAsyncThunk是否正确处理pending/fulfilled/rejected状态,并反映到UI上。
最后分享一个我坚持了5年的习惯:每周五下午,花30分钟,随机打开一个未覆盖的组件,用RTL写一个测试。不求多,只求准。三个月后,你会发现团队的Bug率下降40%,而“react面试题”里那些让人头皮发麻的场景题,答案自然浮现——因为你的肌肉记忆已经把“用户行为→DOM断言→异步等待”刻进了DNA。测试不是负担,它是你写代码时,那个坐在旁边、冷静指出“这里用户会卡住”的资深同事。现在,关掉这个页面,打开你的IDE,选一个最让你不安的组件,开始写第一个render吧。
