
1. 为什么“装完就跑”在 React/Redux 测试里根本行不通我第一次给一个用了 Redux 的 React 项目配 Jest 和 Enzyme是在三年前接手一个电商后台的重构任务。当时团队只有一份 README 里写着“npm test能跑就行”结果npm test执行后直接报错Cannot find module react-dom/test-utils。我照着网上搜到的三篇教程依次执行删了node_modules、清了yarn cache、重装了jest、又装了enzyme-adapter-react-16……最后发现项目用的是 React 17而适配器版本写的是16——光是版本对不上就卡了整整一个下午。这不是个例。你看到的热搜词里反复出现installation failed、no valid maven installation found、existing installation is up to date表面看是环境问题背后其实是测试生态的隐性契约被打破了Jest 不只是个“跑测试的命令”它是一套运行时沙盒Enzyme 不是“渲染组件的工具”它是对 React 内部协调器reconciler的一层模拟封装而 Redux 的 store 创建方式直接决定了你能不能在测试中干净地重置状态。这三者之间没有官方强绑定关系但一旦组合使用就会形成一套脆弱的依赖链——某个包升级了 minor 版本可能就让整个测试套件集体失能。所以“Installation Setup”从来不是点几下回车的事。它本质是一次环境可信度校验你要确认 Jest 能正确加载 React 的测试上下文Enzyme 能准确识别当前 React 的生命周期钩子调用顺序Redux store 能在每次测试前被彻底销毁重建且不残留上一次测试的副作用。这就像给手术室消毒——不是喷两下酒精就算完而是要测每个角落的菌落数是否低于阈值。我后来把这套 setup 过程拆成了四个不可跳过的硬性检查点React 版本与适配器的 ABI 兼容性不是语义化版本匹配而是底层 fiber 节点结构是否一致Jest 配置中 moduleNameMapper 的路径映射是否覆盖了所有别名和绝对路径引用否则import Button from components/Button在测试里会直接报错Redux store 初始化逻辑是否支持“可销毁实例化”即不能在模块顶层createStore()必须封装成函数Enzyme 的 adapter 设置是否在 Jest 的 setupFilesAfterEnv 中被提前执行否则shallow()调用时会找不到ReactTestRenderer实例。这些细节不会出现在任何“5 分钟上手”教程里但它们才是决定你后续写一百个测试用例会不会在 CI 上随机失败的关键。接下来我就带你从零开始把这四个检查点全部落地为可验证、可复现、可审计的配置步骤——不是复制粘贴而是每一步都告诉你“为什么非得这样写”。2. 从 package.json 到 jest.config.js配置文件的逐层解耦逻辑很多人以为 setup 就是npm install --save-dev jest testing-library/react enzyme enzyme-adapter-react-16然后npx jest --init一路回车。这确实能生成一个能跑的配置但三个月后当你想加一个快照测试或者把 Enzyme 换成 RTL你会发现jest.config.js里堆满了互相冲突的transformIgnorePatterns和moduleNameMapper改一处十处报错。真正的 setup是从package.json的scripts字段开始设计的。我现在的标准写法是scripts: { test: jest, test:watch: jest --watch, test:ci: jest --ci --coverage --maxWorkers2, test:debug: node --inspect-brk node_modules/jest/bin/jest.js --runInBand }注意三个关键点test:ci明确指定--maxWorkers2而不是默认的--maxWorkers50%。因为 CI 环境通常只有 2 核 CPU开太多 worker 会导致内存溢出我们曾因此在 GitHub Actions 上频繁失败test:debug加了--runInBand强制单线程运行避免调试时断点跳来跳去所有命令都不带--config参数意味着jest.config.js必须放在项目根目录且不能依赖环境变量动态加载——这是为了保证本地、CI、同事电脑三端行为完全一致。接下来是jest.config.js。我拒绝用npx jest --init生成的默认配置而是手写一个最小可行集// jest.config.js module.exports { // 1. 运行环境明确指定是 Node 还是 jsdom testEnvironment: jsdom, // 2. 入口文件告诉 Jest 哪些文件需要被处理 testMatch: [ rootDir/src/**/__tests__/**/*.{js,jsx,ts,tsx}, rootDir/src/**/*.{spec,test}.{js,jsx,ts,tsx} ], // 3. 模块解析这是最容易出错的地方 moduleNameMapper: { \\.(css|less|scss|sass)$: identity-obj-proxy, \\.(jpg|jpeg|png|gif|webp|svg)$: rootDir/__mocks__/fileMock.js, ^/(.*)$: rootDir/src/$1 }, // 4. 转换规则只转译 src 下的代码node_modules 一律不碰 transform: { ^.\\.(js|jsx|ts|tsx)$: rootDir/node_modules/babel-jest }, // 5. 预设这里不写 preset而是显式声明所有依赖 setupFilesAfterEnv: [rootDir/src/setupTests.js], // 6. 覆盖率只统计 src 目录排除测试文件和类型定义 collectCoverageFrom: [ src/**/*.{js,jsx,ts,tsx}, !src/**/*.test.{js,jsx,ts,tsx}, !src/**/index.{js,jsx,ts,tsx}, !src/types/** ] };重点说moduleNameMapper。热搜词里反复出现react antd table rowselection 卡顿、react fetch提示 you need to enable javascript这些问题的根源往往就在这里。比如^/(.*)$这个正则它把/components/Button映射到src/components/Button。但如果项目里同时存在/api和/API大小写混用这个正则就会失效——Jest 默认区分大小写而 Windows 文件系统不区分。解决方案不是关掉大小写检查而是强制统一路径规范在 ESLint 里加一条规则import/no-unresolved: [error, { caseSensitive: true }]让开发阶段就暴露路径问题。再看transform。很多教程教你在transform里写^.\\.(js|jsx|ts|tsx)$: babel-jest但如果你的项目用的是 TypeScriptbabel-jest默认不处理.ts文件里的类型注解。必须显式安装babel/preset-typescript并在.babelrc中配置{ presets: [ babel/preset-env, babel/preset-react, babel/preset-typescript ] }否则const a: string hello这行代码在 Jest 里会被当成语法错误。这不是 Jest 的 bug而是 Babel 的职责边界问题——Jest 只负责调用转换器不负责提供转换能力。最后是setupFilesAfterEnv。这个字段指向src/setupTests.js它的内容必须包含 Enzyme 的 adapter 初始化// src/setupTests.js import { configure } from enzyme; import Adapter from enzyme-adapter-react-17; configure({ adapter: new Adapter() });注意Adapter必须是enzyme-adapter-react-17不是16或18。React 17 的 fiber 架构引入了新的事件委托机制不再是 document 上统一捕获Enzyme 的 adapter 必须精确模拟这一行为否则simulate(click)可能触发不到目标元素。我见过最诡异的 case 是同一个onClick处理函数在shallow()渲染下能触发在mount()下却静默失败——就是因为 adapter 版本错配导致事件冒泡路径被截断。提示enzyme-adapter-react-17已停止维护但目前仍是 React 17 项目的事实标准。不要试图用enzyme-adapter-react-18替代后者依赖 React 18 的 concurrent features而你的项目大概率还没启用createRoot。3. React Redux 的测试沙盒store 实例化的三种致命陷阱Redux 的测试 setup 最容易被忽略的是 store 的创建时机。我见过至少三种典型的反模式陷阱一模块顶层创建 store// ❌ 错误示范store 在模块顶层创建 import { createStore, applyMiddleware } from redux; import rootReducer from ./reducers; const store createStore(rootReducer); // ← 这里 export default store;问题在于Jest 默认对每个测试文件做模块隔离module isolation但store是单例对象一旦在某个测试里 dispatch 了 action它的 state 就污染了全局。下一个测试读取store.getState()时拿到的是上一个测试留下的脏数据。更糟的是如果你用jest.mock(./store)想 mock 它由于模块缓存机制mock 可能根本不起作用。陷阱二测试文件内创建 store但未清理// ❌ 错误示范创建了 store但没重置 import configureStore from redux-mock-store; import { Provider } from react-redux; import { mount } from enzyme; const mockStore configureStore([]); describe(MyComponent, () { let store; beforeEach(() { store mockStore({ user: { name: test } }); }); it(renders correctly, () { const wrapper mount( Provider store{store} MyComponent / /Provider ); expect(wrapper.find(h1).text()).toBe(test); }); });这段代码看似没问题但mockStore创建的 store 是基于redux-mock-store的内存 store它不支持replaceReducer也无法在测试后自动清空。如果MyComponent里有异步 action比如fetchUser()mockStore会把所有 dispatched actions 存在内部数组里导致后续测试的expect(store.getActions()).toEqual(...)断言失败。陷阱三用createStore但没传入初始 state// ❌ 错误示范createStore 无初始 state import { createStore } from redux; import rootReducer from ./reducers; describe(MyComponent, () { let store; beforeEach(() { store createStore(rootReducer); // ← 缺少初始 state }); it(handles loading state, () { store.dispatch({ type: FETCH_USER_REQUEST }); expect(store.getState().user.loading).toBe(true); }); });createStore如果不传初始 state它会用rootReducer(undefined, {})初始化而 reducer 里通常有if (state undefined) return initialState的逻辑。但initialState往往是{ user: null, loading: false }而loading: false会让测试无法验证loading: true的状态。正确的做法是把 store 创建封装成函数并在每个测试前调用且确保返回全新实例。我现在的标准模板是// src/testUtils/storeFactory.js import { createStore, applyMiddleware, compose } from redux; import thunk from redux-thunk; import rootReducer from ../reducers; export const createTestStore (initialState {}) { const middleware [thunk]; const enhancer compose(applyMiddleware(...middleware)); // 关键合并初始 state确保 reducer 有确定的起点 const mergedState { user: { name: , loading: false, error: null }, posts: { list: [], loading: false }, ...initialState }; return createStore(rootReducer, mergedState, enhancer); }; // src/testUtils/renderWithRedux.js import { Provider } from react-redux; import { createTestStore } from ./storeFactory; export const renderWithRedux ( component, { initialState, store createTestStore(initialState) } {} ) { return { ...render(Provider store{store}{component}/Provider), store }; };使用时// MyComponent.test.js import { renderWithRedux } from ../testUtils/renderWithRedux; import MyComponent from ../MyComponent; describe(MyComponent, () { it(shows loading spinner when fetching, () { const { getByTestId } renderWithRedux(MyComponent /, { initialState: { user: { loading: true } } }); expect(getByTestId(spinner)).toBeInTheDocument(); }); it(shows user name when loaded, () { const { getByText } renderWithRedux(MyComponent /, { initialState: { user: { name: Alice, loading: false } } }); expect(getByText(Alice)).toBeInTheDocument(); }); });这个模式解决了所有陷阱createTestStore每次调用都返回新 store 实例initialState显式传入确保测试可预测renderWithRedux封装了Provider避免每个测试都写重复代码store实例暴露给测试方便store.dispatch()和store.getState()断言。注意createTestStore里不要用redux-devtools-extension的window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__因为测试环境没有window对象。CI 上会直接报错ReferenceError: window is not defined。4. Enzyme 的 shallow/mount/render 三剑客何时用谁怎么用才不翻车Enzyme 的三个渲染方法shallow、mount、render经常被滥用。我刚学的时候以为mount最“真实”所以所有测试都用它结果 CI 构建时间从 30 秒涨到 3 分钟——因为mount会完整挂载 DOM触发所有生命周期包括useEffect里的fetch请求。而我们的测试环境根本没有 mock 掉网络请求导致测试超时失败。先说结论90% 的单元测试应该用shallow5% 用render只有 5% 真正需要mount。这不是教条而是基于 React 组件分层模型的理性选择。4.1 shallow组件的“皮肤层”测试shallow只渲染组件自身不渲染子组件child components。它模拟的是 React 的“浅渲染”机制即只执行当前组件的render方法返回的虚拟 DOM 节点里子组件都是ChildComponent /这样的占位符而不是实际 DOM。适用场景测试组件自身的 props 传递是否正确测试组件内部 state 变化是否触发预期的 UI 更新测试事件处理器如onClick是否被正确绑定和调用。经典案例测试一个按钮组件是否把disabledprop 正确传给了原生button// Button.jsx const Button ({ children, disabled, onClick }) ( button disabled{disabled} onClick{onClick} {children} /button ); export default Button;测试// Button.test.js import { shallow } from enzyme; import Button from ./Button; describe(Button, () { it(passes disabled prop to button element, () { const wrapper shallow(Button disabled{true}Click me/Button); // shallow 渲染后wrapper 是 button disabledtrueClick me/button expect(wrapper.prop(disabled)).toBe(true); expect(wrapper.find(button).prop(disabled)).toBe(true); }); it(calls onClick when clicked, () { const mockOnClick jest.fn(); const wrapper shallow(Button onClick{mockOnClick}Click me/Button); wrapper.find(button).simulate(click); expect(mockOnClick).toHaveBeenCalledTimes(1); }); });这里wrapper.find(button)能成功是因为shallow把button当作原生 DOM 元素处理了它内置了 HTML 元素白名单。但如果你写wrapper.find(SomeOtherComponent)它会返回空因为SomeOtherComponent是自定义组件shallow不会深入渲染它。提示shallow的最大优势是快。它不触发useEffect、不挂载 DOM、不执行子组件逻辑一个测试通常在 2~5ms 内完成。这也是为什么它适合高频运行的单元测试。4.2 render服务端渲染SSR风格的静态快照render方法用 Cheerio一个服务器端的 jQuery 实现将组件渲染成静态 HTML 字符串。它不挂载到真实 DOM也不支持交互比如simulate(click)会报错但它能渲染所有子组件——只要那些子组件不依赖浏览器 API如window、document。适用场景生成 HTML 快照snapshot testing测试组件最终输出的 HTML 结构是否符合预期验证 CSS 类名、data 属性、aria 标签等静态属性。案例测试一个带>// LoginForm.jsx const LoginForm () ( form>// LoginForm.test.js import { render } from enzyme; import LoginForm from ./LoginForm; describe(LoginForm, () { it(renders form with correct test ids, () { const $ render(LoginForm /); // Cheerio 语法用 $ 选择器查询 expect($(form).attr(data-testid)).toBe(login-form); expect($(input[typepassword])).toHaveLength(1); expect($(button).attr(data-testid)).toBe(submit-button); }); });注意render返回的是 Cheerio 实例$不是 Enzyme Wrapper。所以不能用wrapper.find()而要用$()语法。这也是它和shallow/mount的根本区别。4.3 mount真实的浏览器环境模拟mount会把组件挂载到真实 DOMJSDOM 提供的虚拟 DOM并触发完整的生命周期constructor→render→componentDidMount/useEffect→componentDidUpdate。它能响应事件、执行异步操作、访问ref是最接近真实用户行为的测试方式。但代价巨大每次mount都要创建 JSDOM 环境耗时是shallow的 10~20 倍如果组件里有useEffect(() { fetch(/api) }, [])测试会发起真实网络请求除非你用jest.mock(node-fetch)或msw拦截mount后的组件状态会污染全局必须手动wrapper.unmount()清理。适用场景测试依赖ref的组件如focus()、scrollIntoView()测试useEffect里的副作用逻辑测试 Context Provider 的跨层级数据传递E2E 前的集成测试integration test。案例测试一个自动聚焦的输入框// AutoFocusInput.jsx import { useRef, useEffect } from react; const AutoFocusInput () { const inputRef useRef(null); useEffect(() { if (inputRef.current) { inputRef.current.focus(); // ← 这行需要真实 DOM } }, []); return input ref{inputRef}>// AutoFocusInput.test.js import { mount } from enzyme; import AutoFocusInput from ./AutoFocusInput; describe(AutoFocusInput, () { it(focuses input on mount, () { const wrapper mount(AutoFocusInput /); // mount 后inputRef.current 指向真实 DOM 元素 const input wrapper.find(input).getDOMNode(); expect(document.activeElement).toBe(input); wrapper.unmount(); // 必须卸载否则影响下一个测试 }); });注意mount测试必须加wrapper.unmount()否则 JSDOM 的document.body会残留节点导致后续测试的document.querySelector()找到错误元素。我曾经因为漏写这行让一个测试随机失败了两周。5. 从“能跑”到“可信”setup 阶段的四步验证清单配置写完不等于 setup 完成。我给自己定了一套上线前的四步验证清单每一步都对应一个具体命令和预期输出。只有全部通过我才认为这个项目的测试环境是“可信”的。5.1 验证 Jest 运行时环境执行命令npx jest --no-cache --runInBand --testNamePattern^environment test$ --verbose创建一个临时测试文件src/__tests__/environment.test.jsdescribe(environment test, () { it(should have jsdom available, () { expect(typeof document).toBe(object); expect(typeof window).toBe(object); }); it(should support modern JS features, () { expect([1, 2, 3].includes(2)).toBe(true); expect(Object.assign({}, { a: 1 })).toEqual({ a: 1 }); }); });预期输出两个测试都通过且--verbose显示 Jest 使用的是jsdom环境不是node。如果报错ReferenceError: document is not defined说明testEnvironment: jsdom没生效或者setupFilesAfterEnv里有代码提前访问了document。5.2 验证 Enzyme 适配器兼容性执行命令npx jest --no-cache --runInBand --testNamePattern^enzyme adapter test$ --verbose测试文件src/__tests__/enzyme-adapter.test.jsimport { shallow } from enzyme; describe(enzyme adapter test, () { it(should render a simple div, () { const wrapper shallow(div classNametestHello/div); expect(wrapper.hasClass(test)).toBe(true); expect(wrapper.text()).toBe(Hello); }); it(should handle React fragments, () { const wrapper shallow(HellosubWorld/sub/); expect(wrapper.children()).toHaveLength(2); }); });预期输出两个测试通过。如果第二个测试失败wrapper.children().toHaveLength(2)报错说明 Enzyme adapter 版本与 React 不匹配。React 17 的 Fragment 渲染逻辑和 16 不同旧 adapter 会把解析成空节点。5.3 验证 Redux store 隔离性执行命令npx jest --no-cache --runInBand --testNamePattern^redux isolation test$ --verbose测试文件src/__tests__/redux-isolation.test.jsimport { createTestStore } from ../testUtils/storeFactory; describe(redux isolation test, () { it(should create independent store instances, () { const store1 createTestStore({ counter: 0 }); const store2 createTestStore({ counter: 100 }); store1.dispatch({ type: INCREMENT }); store2.dispatch({ type: DECREMENT }); expect(store1.getState().counter).toBe(1); expect(store2.getState().counter).toBe(99); }); it(should not share state between tests, () { const storeA createTestStore({ user: { name: A } }); const storeB createTestStore({ user: { name: B } }); expect(storeA.getState().user.name).toBe(A); expect(storeB.getState().user.name).toBe(B); }); });预期输出两个测试通过。如果第一个测试里store1.getState().counter是100说明createTestStore返回的是同一个引用initialState没被正确合并。5.4 验证组件渲染一致性执行命令npx jest --no-cache --runInBand --testNamePattern^render consistency test$ --verbose测试文件src/__tests__/render-consistency.test.jsimport { shallow, render, mount } from enzyme; import Button from ../Button; describe(render consistency test, () { const defaultProps { children: Click me, onClick: jest.fn() }; it(shallow and render should produce same text content, () { const shallowWrapper shallow(Button {...defaultProps} /); const renderOutput render(Button {...defaultProps} /); expect(shallowWrapper.text()).toBe(Click me); expect(renderOutput.text()).toBe(Click me); }); it(mount should trigger useEffect, () { const mockEffect jest.fn(); const ComponentWithEffect () { useEffect(mockEffect, []); return divMounted/div; }; const wrapper mount(ComponentWithEffect /); expect(mockEffect).toHaveBeenCalledTimes(1); wrapper.unmount(); }); });预期输出三个断言全部通过。如果shallowWrapper.text()是空字符串说明shallow没正确解析children如果mockEffect调用次数不是1说明mount没触发useEffect可能是 adapter 或 React 版本问题。这四步验证清单我把它写进了团队的CONTRIBUTING.md要求每个新成员 PR 前必须运行。它不解决所有问题但能拦截 80% 的 setup 阶段低级错误。真正的测试稳定性永远始于一个可验证、可审计、可复现的初始化过程——而不是一份“能跑就行”的配置文件。我在实际项目中发现花两个小时写好这四步验证比花两天调试一个随机失败的测试要高效得多。因为 setup 的问题往往不是“不能跑”而是“跑得不稳定”。而这种不稳定会在你最不想出问题的时候爆发——比如上线前夜或者面试官让你现场写测试的时候。