Jest如何mock同一文件内的全局变量?附React组件测试示例
和被测对象同文件的顶层全局变量,执行时机早于测试文件里普通mock逻辑的执行时机:当你通过import语句引入组件模块时,组件文件内的所有顶层代码会立刻同步执行,也就是说const popupRoot = document.getElementById('popup-root')这行代码,会在你测试文件里重写window.document.getElementById的逻辑之前就跑完,此时拿到的popupRoot值为null,后续执行ReactDOM.createPortal、访问popupRoot.children时自然会抛出空指针错误。
你把popupRoot移到组件内部时,变量初始化时机推迟到组件mount阶段,此时你写的getElementByIdmock已经生效,所以测试能正常运行。
方案1:调整mock时序,DOM mock完成后再导入组件
Jest 仅会自动提升jest.mock相关的mock逻辑到文件最顶部执行,普通的对象重写、Object.defineProperty逻辑不会被提升,因此可以用动态导入的方式,等所有DOM mock执行完毕后再引入被测组件:
import Enzyme, { mount } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; Enzyme.configure({ adapter: new Adapter() }); // 提前执行所有DOM mock const toggleClasslist = jest.fn(); Object.defineProperty(document, 'body', { value: { classList: { toggle: toggleClasslist } }, writable: true, configurable: true }); const mockPopupRoot = document.createElement('div'); window.document.getElementById = jest.fn().mockReturnValue(mockPopupRoot); describe('PopupWrapper component test', () => { let wrapper: any; let PopupWrapper: typeof import('./Testing').default; beforeAll(async () => { // mock完成后动态导入组件 PopupWrapper = (await import('./Testing')).default; wrapper = mount(<PopupWrapper />); }); it('should call classList.toggle("noscroll",true) on mount', () => { expect(toggleClasslist).toHaveBeenCalledWith('noscroll', true); }); it('should call classList.toggle("noscroll",false) on unmount', () => { wrapper.unmount(); expect(toggleClasslist).toHaveBeenCalledWith('noscroll', false); }); });
如果用jsdom测试环境,更简单的做法是在Jest配置的setupFilesAfterEnv对应的setup文件里,提前往DOM插入id为popup-root的真实节点,不需要mockgetElementById,组件导入时就能直接拿到对应节点。
方案2:导出顶层变量,通过模块mock重写
如果不想用动态导入,可以先把组件文件内的popupRoot导出,再用jest.mock重写该变量的值:
首先修改组件文件:
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; // 导出顶层变量 export const popupRoot = document.getElementById('popup-root') as HTMLElement; const PopupWrapper = () => { useEffect(() => { document.body.classList.toggle('noscroll', true); return () => { if (popupRoot.children.length === 0) document.body.classList.toggle('noscroll', false); }; }, []); return ReactDOM.createPortal(<p>Hello</p>, popupRoot); }; export default PopupWrapper;
然后修改测试文件:
import Enzyme, { mount } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; const mockPopupRoot = document.createElement('div'); const toggleClasslist = jest.fn(); // 模块mock会被Jest自动提升到导入语句前执行 jest.mock('./Testing', () => { const actualModule = jest.requireActual('./Testing'); return { ...actualModule, popupRoot: mockPopupRoot } }); Object.defineProperty(document, 'body', { value: { classList: { toggle: toggleClasslist } }, writable: true, configurable: true }); import PopupWrapper from './Testing'; Enzyme.configure({ adapter: new Adapter() }); describe('PopupWrapper component test', () => { let wrapper: any; beforeAll(() => { wrapper = mount(<PopupWrapper />); }); it('should call classList.toggle("noscroll",true) on mount', () => { expect(toggleClasslist).toHaveBeenCalledWith('noscroll', true); }); it('should call classList.toggle("noscroll",false) on unmount', () => { wrapper.unmount(); expect(toggleClasslist).toHaveBeenCalledWith('noscroll', false); }); });
方案3:优化代码结构(长期推荐)
把DOM查询逻辑放在模块顶层本身存在隐患:如果线上环境脚本加载时机早于#popup-root节点渲染,同样会出现和测试中一致的空指针错误。更稳妥的写法有两种:
- 把DOM查询逻辑移到组件内部,用
useMemo缓存查询结果,不会产生额外性能损耗,也就是你注释掉的写法; - 把DOM节点获取逻辑抽离到独立的工具函数中,测试时仅需要mock对应工具函数即可,不需要处理同文件顶层变量的时序问题。
你遇到的Cannot read properties of null (reading 'children')错误,正是popupRoot初始化时拿到null导致的,调整时序或者代码结构即可解决。
内容的提问来源于stack exchange,提问作者LY Hooi

