React+TS+React Query无限滚动测试失败,求排查方案
问题
开发React + TypeScript + React Query项目时,编写MainGrid组件的无限滚动测试始终失败:
expect(IntersectionObserverMock).toHaveBeenCalled()报错,模拟的IntersectionObserverMock未被调用- 控制台显示
IntersectionObserverMock.mock.instances[0]返回undefined
相关代码如下:
MainGrid组件
// MainGrid组件代码 import { useNavigate } from 'react-router-dom'; import styled from "styled-components"; import useInfiniteScroll from '../hooks/use-infinitescroll'; import LazyLoadingImage from './LazyLoading'; import PlaceholderImg from '../assets/images/placeholderImg.svg'; import { productQuery } from '../hooks/useProductQuery'; function MainGrid() { const navigate = useNavigate() const { data, hasNextPage, fetchNextPage } = productQuery.useGetProducts(); const target = useInfiniteScroll({ hasNextPage, fetchNextPage, }) return ( <Container> { data?.pages.flat().map((p, i) => { return <div key={p?.product_id}> <LazyLoadingImage src={p?.image} onError={(e: React.ChangeEvent<HTMLImageElement>) => { e.target.onerror = null; e.target.src = p.image e.target.width = 380; e.target.height = 380; }} alt={p?.product_name} onClick={() => navigate(`/detail/${p?.product_id}`)} placeholderImg={PlaceholderImg} /> <p className='product-name'>{p?.store_name}</p> <p className='product'>{p?.product_name}</p> <span className='product-price'>{p?.price?.toLocaleString()}</span> <span>원</span> </div> }) } <div ref={target} data-testid='scroll-target'></div> </Container> ) }
useInfiniteScroll自定义Hook
// useInfiniteScroll自定义hook代码 import { InfiniteQueryObserverResult } from '@tanstack/react-query'; import { useCallback, useEffect, useRef } from 'react'; type IntersectionObserverProps = { hasNextPage?: boolean | false; fetchNextPage?: () => Promise<InfiniteQueryObserverResult>; onIntersection?: (entry: IntersectionObserverEntry[], observer: IntersectionObserver) => void; } function useInfiniteScroll({ hasNextPage, fetchNextPage, onIntersection }: IntersectionObserverProps) { const ref = useRef<HTMLDivElement>(null); const handleIntersect: IntersectionObserverCallback = useCallback(([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => { if (onIntersection) { onIntersection(entry[0], observer); } if (entry?.isIntersecting && hasNextPage) { fetchNextPage(); } }, [fetchNextPage, hasNextPage, onIntersection]); useEffect(() => { let observer: IntersectionObserver; if (ref.current) { observer = new IntersectionObserver(handleIntersect, { threshold: 0.6, }); observer.observe(ref.current); } return () => observer && observer.disconnect(); }, [ref, handleIntersect]); return ref; } export default useInfiniteScroll
测试代码
// 测试代码 import { render, screen, waitFor } from '@testing-library/react'; import MainGrid from "../MainGrid"; import { act } from '@testing-library/react'; import { productQuery } from "../../hooks/useProductQuery"; import { InfiniteData, QueryClient, QueryClientProvider, UseInfiniteQueryResult } from '@tanstack/react-query'; import { Product } from '../types/product'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import useInfiniteScroll from '../../hooks/use-infinitescroll'; jest.mock('../../hooks/use-infinitescroll', () => ({ __esModule: true, default: jest.fn(() => ({ current: null })), })); jest.mock('../../hooks/useProductQuery', () => ({ productQuery: { useGetProducts: jest.fn(), } })); const mockedUseGetProducts = jest.mocked(productQuery.useGetProducts); const IntersectionObserverMock = jest.fn((callback, options = {}) => { const instance = { thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold], root: options.root, rootMargin: options.rootMargin, observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), trigger: () => callback([{ isIntersecting: true }], instance) }; return instance; }); (global as any).IntersectionObserver = IntersectionObserverMock; const mockProduct = { pages: [ Array.from({ length: 15 }, (_, i) => ({ product_id: i, image: `image_url${i}`, product_name: `Product ${i}`, store_name: `Stroe ${i}`, price: 1000 + i })) ], }; const queryClient = new QueryClient(); describe('MainGrid Component', () => { afterEach(() => { jest.clearAllMocks(); }) it('should render products and load more on scroll', async () => { const fetchNextPage = jest.fn(); const hasNextPage = true; const ref = { current: null }; (useInfiniteScroll as jest.Mock).mockReturnValue(ref); mockedUseGetProducts.mockReturnValue({ data: { pages: mockProduct.pages, pageParams: [1], }, hasNextPage, fetchNextPage } as unknown as UseInfiniteQueryResult<InfiniteData<Product[], unknown>, Error>); render( <QueryClientProvider client={queryClient}> <MemoryRouter> <Routes> <Route path="/" element={<MainGrid />} /> </Routes> </MemoryRouter> </QueryClientProvider> ); ref.current = screen.getByTestId('scroll-target') useInfiniteScroll({ hasNextPage, fetchNextPage }); const instance = IntersectionObserverMock.mock.instances[0]; if (!instance) { console.error("IntersectionObserverMock instance is undefined"); } else { instance.trigger(); } expect(IntersectionObserverMock).toHaveBeenCalled(); await waitFor(() => { expect(fetchNextPage).toHaveBeenCalled(); }); }); });
问题排查与修复方案
核心问题分析
- Mock覆盖真实Hook逻辑:测试中
jest.mock('../../hooks/use-infinitescroll')直接替换了真实的useInfiniteScroll实现,导致初始化IntersectionObserver的代码根本没执行,自然不会调用你模拟的IntersectionObserverMock。 - 手动调用Hook无效:测试里手动调用
useInfiniteScroll({ hasNextPage, fetchNextPage })属于脱离组件上下文调用,和组件内的Hook调用完全无关,无法触发真实逻辑。 - Ref赋值时机错误:即使不Mock Hook,初始ref.current为null,组件渲染后才会关联DOM元素,但useInfiniteScroll的useEffect依赖ref,只有ref变化时才会重新执行创建Observer的逻辑。
修复步骤
- 移除useInfiniteScroll的Mock:让测试使用真实Hook逻辑,仅Mock全局的IntersectionObserver即可。
- 利用组件自身的Ref关联:组件渲染后,ref会自动绑定到
scroll-target元素,无需手动赋值。 - 直接触发Observer回调:从Mock的IntersectionObserver中保存回调函数,模拟交叉事件触发。
修改后的测试代码
import { render, screen, waitFor } from '@testing-library/react'; import MainGrid from "../MainGrid"; import { productQuery } from "../../hooks/useProductQuery"; import { InfiniteData, QueryClient, QueryClientProvider, UseInfiniteQueryResult } from '@tanstack/react-query'; import { Product } from '../types/product'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; // 移除useInfiniteScroll的Mock,使用真实Hook逻辑 jest.mock('../../hooks/useProductQuery', () => ({ productQuery: { useGetProducts: jest.fn(), } })); const mockedUseGetProducts = jest.mocked(productQuery.useGetProducts); let mockIntersectionCallback: IntersectionObserverCallback; const IntersectionObserverMock = jest.fn((callback: IntersectionObserverCallback) => { mockIntersectionCallback = callback; // 保存回调函数用于后续触发 return { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), }; }); (global as any).IntersectionObserver = IntersectionObserverMock; const mockProduct = { pages: [ Array.from({ length: 15 }, (_, i) => ({ product_id: i, image: `image_url${i}`, product_name: `Product ${i}`, store_name: `Store ${i}`, price: 1000 + i })) ], }; const queryClient = new QueryClient(); describe('MainGrid Component', () => { afterEach(() => { jest.clearAllMocks(); mockIntersectionCallback = null; }) it('should render products and load more when scroll target is intersected', async () => { const fetchNextPage = jest.fn().mockResolvedValue({}); const hasNextPage = true; mockedUseGetProducts.mockReturnValue({ data: { pages: mockProduct.pages, pageParams: [1], }, hasNextPage, fetchNextPage } as unknown as UseInfiniteQueryResult<InfiniteData<Product[], unknown>, Error>); render( <QueryClientProvider client={queryClient}> <MemoryRouter> <Routes> <Route path="/" element={<MainGrid />} /> </Routes> </MemoryRouter> </QueryClientProvider> ); // 验证IntersectionObserver已初始化并监听目标元素 expect(IntersectionObserverMock).toHaveBeenCalled(); expect(IntersectionObserverMock.mock.instances[0].observe).toHaveBeenCalledWith(screen.getByTestId('scroll-target')); // 模拟交叉事件触发无限滚动逻辑 mockIntersectionCallback([{ isIntersecting: true }], {} as IntersectionObserver); // 验证fetchNextPage被调用 await waitFor(() => { expect(fetchNextPage).toHaveBeenCalled(); }); }); });
额外优化点
- 修正数据拼写错误:将原mock中的
Stroe改为Store,保持数据一致性。 - 简化IntersectionObserverMock:仅保留必要方法,直接保存回调函数方便后续触发。
- 添加fetchNextPage的Mock实现:确保Promise正常resolve,避免测试中出现未处理Promise警告。
内容的提问来源于stack exchange,提问作者cloudia
相关产品推荐
相关产品推荐

