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 --maxWorkers=2", "test:debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand" }注意三个关键点:
test:ci明确指定--maxWorkers=2,而不是默认的--maxWorkers=50%。因为 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',但如果你的项目用的是 TypeScript,babel-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% 的单元测试应该用shallow,5% 用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 disabled="true">Click 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[type="password"]')).toHaveLength(1); expect($('button').attr('data-testid')).toBe('submit-button'); }); });
注意:render返回的是 Cheerio 实例$,不是 Enzyme Wrapper。所以不能用wrapper.find(),而要用$()语法。这也是它和shallow/mount的根本区别。
4.3 mount:真实的浏览器环境模拟
mount会把组件挂载到真实 DOM(JSDOM 提供的虚拟 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.js:
describe('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.js:
import { shallow } from 'enzyme'; describe('enzyme adapter test', () => { it('should render a simple div', () => { const wrapper = shallow(<div className="test">Hello</div>); expect(wrapper.hasClass('test')).toBe(true); expect(wrapper.text()).toBe('Hello'); }); it('should handle React fragments', () => { const wrapper = shallow(<>Hello<sub>World</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.js:
import { 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.js:
import { 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 <div>Mounted</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 的问题,往往不是“不能跑”,而是“跑得不稳定”。而这种不稳定,会在你最不想出问题的时候爆发——比如上线前夜,或者面试官让你现场写测试的时候。