You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 23:09:24