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

如何在React Testing Library中正确Mock多个useRef测试自定义滚动Hook?

自定义Hook单元测试问题:useScrollArea的测试修复方案

问题背景

我开发了一个用于构建自定义滚动组件的自定义Hook,功能正常,但单元测试无法覆盖,怀疑是useRef的Mock方式有问题,也可能存在其他遗漏点。尝试了大量文章和帖子都没解决,持续遇到TypeError: Cannot read properties of undefined (reading 'current')错误,或者Hook返回结果不符合预期。

Hook代码

export const useScrollArea = () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const scrollBarThumbRef = useRef<HTMLDivElement>(null);
  const [isScrollable, setIsScrollable] = useState(false);

  const isOverflowing = useCallback(() => {
    if (!containerRef.current || !contentRef.current) {
      return false;
    }

    const isContentOverflowing =
      contentRef.current.scrollHeight > containerRef.current.clientHeight;

    setIsScrollable(isContentOverflowing);
  }, []);

  const updateThumbPosition = useCallback(() => {
    const scrollRatio =
      containerRef.current.scrollTop /
      (contentRef.current.scrollHeight - containerRef.current.clientHeight);

    const thumbTop =
      scrollRatio *
      (containerRef.current.clientHeight -
        scrollBarThumbRef.current.clientHeight);

    scrollBarThumbRef.current.style.transform = `translateY(${thumbTop}px)`;
  }, []);

  const scroll = (direction: ScrollDirection) => {
    if (
      !containerRef?.current ||
      !contentRef?.current ||
      !scrollBarThumbRef?.current ||
      !isScrollable
    ) {
      return;
    }

    const maxScrollPosition =
      contentRef.current.scrollHeight - containerRef.current.clientHeight;
    let newScrollPosition =
      containerRef.current.scrollTop +
      (direction === 'up' ? -1 : 1) * SCROLL_STEP;

    newScrollPosition = Math.max(
      0,
      Math.min(newScrollPosition, maxScrollPosition)
    );

    containerRef.current.scroll({ top: newScrollPosition });

    updateThumbPosition();
  };

  useEffect(() => {
    isOverflowing();
  }, [isOverflowing]);

  return {
    scroll,
    isScrollable,
    contentRef,
    containerRef,
    scrollBarThumbRef
  };
};

现有测试代码

const mockContainerRef = {
  current: {
    clientHeight: 100,
    scrollTop: 0,
    scroll: jest.fn()
  }
};

const mockContentRef = {
  current: {
    scrollHeight: 200
  }
};

const mockScrollBarThumbRef = {
  current: {
    clientHeight: 50,
    style: {
      transform: ''
    }
  }
};

jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useRef: jest
    .fn()
    .mockImplementationOnce(() => mockContainerRef)
    .mockImplementationOnce(() => mockContentRef)
    .mockImplementationOnce(() => mockScrollBarThumbRef)
}));

describe('useScrollArea', () => {
  it('should return `isScrollable` as true when content is overflowing', () => {
    const { result } = renderHook(() => useScrollArea());

    expect(result.current.isScrollable).toBe(true);
  });

  it('should return `isScrollable` as false when content is not overflowing', () => {
    mockContentRef.current.scrollHeight = 50;

    const { result } = renderHook(() => useScrollArea());

    expect(result.current.isScrollable).toBe(false);
  });

  it('should scroll down', () => {
    mockContainerRef.current.scrollTop = 100;

    const { result } = renderHook(() => useScrollArea());

    act(() => {
      result.current.scroll('down');
    });

    expect(mockContainerRef.current.scroll).toHaveBeenCalledWith({ top: 40 });
  });
});

遇到的错误

TypeError: Cannot read properties of undefined (reading 'current')

问题分析与修复方案

1. 修复useRef的Mock复用问题

你使用mockImplementationOnce只会在第一次调用useRef时返回对应mock,后续测试用例中useRef的调用会因为没有后续mock定义而返回undefined,直接导致current读取失败。

修复方式:
让useRef的mock按调用顺序循环返回三个ref,同时在每个测试用例前重置mock和ref的初始状态:

// 替换原jest.mock部分
jest.mock('react', () => {
  const originalReact = jest.requireActual('react');
  return {
    ...originalReact,
    useRef: jest.fn(() => {
      const refs = [mockContainerRef, mockContentRef, mockScrollBarThumbRef];
      const currentIndex = (useRef.mock.calls.length - 1) % refs.length;
      return refs[currentIndex];
    })
  };
});

// 在每个测试用例前重置mock和ref状态
beforeEach(() => {
  jest.clearAllMocks();
  // 重置ref的初始值
  mockContainerRef.current = {
    clientHeight: 100,
    scrollTop: 0,
    scroll: jest.fn()
  };
  mockContentRef.current = {
    scrollHeight: 200
  };
  mockScrollBarThumbRef.current = {
    clientHeight: 50,
    style: { transform: '' }
  };
});

2. 补充SCROLL_STEP常量定义

测试代码中未定义SCROLL_STEP常量,导致scroll函数计算时出错,这也是引发错误的潜在原因。

修复方式:
在测试文件顶部添加和Hook中一致的常量定义:

const SCROLL_STEP = 60; // 替换为你Hook中实际使用的数值

3. 修正测试用例逻辑

  • 第二个测试用例中,修改mockContentRef.current.scrollHeight后,要确保在renderHook前完成设置,避免闭包读取旧值;
  • 第三个测试用例的预期值错误:当scrollTop初始为100,maxScrollPosition为100(200-100)时,向下滚动不会超过最大值,预期值应调整为100而非40。

修正后的测试用例示例:

it('should return `isScrollable` as false when content is not overflowing', () => {
  // 在renderHook前设置值
  mockContentRef.current.scrollHeight = 50;
  const { result } = renderHook(() => useScrollArea());
  expect(result.current.isScrollable).toBe(false);
});

it('should scroll down and cap at max position', () => {
  mockContainerRef.current.scrollTop = 100;
  const { result } = renderHook(() => useScrollArea());

  act(() => {
    result.current.scroll('down');
  });

  // 不会超过maxScrollPosition=100
  expect(mockContainerRef.current.scroll).toHaveBeenCalledWith({ top: 100 });
});

4. 测试thumb位置更新逻辑

可以添加测试用例验证滚动时滚动条thumb的位置是否正确更新:

it('should update thumb position when scrolling', () => {
  mockContainerRef.current.scrollTop = 50;
  mockContentRef.current.scrollHeight = 200;
  mockContainerRef.current.clientHeight = 100;
  mockScrollBarThumbRef.current.clientHeight = 50;

  const { result } = renderHook(() => useScrollArea());

  // 通过scroll触发updateThumbPosition
  act(() => {
    result.current.scroll('down');
  });

  // 计算:scrollRatio=50/(200-100)=0.5;thumbTop=0.5*(100-50)=25
  expect(mockScrollBarThumbRef.current.style.transform).toBe('translateY(25px)');
});

内容的提问来源于stack exchange,提问作者t97

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 13:24:56