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

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();
        });
    });
});

问题排查与修复方案

核心问题分析

  1. Mock覆盖真实Hook逻辑:测试中jest.mock('../../hooks/use-infinitescroll')直接替换了真实的useInfiniteScroll实现,导致初始化IntersectionObserver的代码根本没执行,自然不会调用你模拟的IntersectionObserverMock。
  2. 手动调用Hook无效:测试里手动调用useInfiniteScroll({ hasNextPage, fetchNextPage })属于脱离组件上下文调用,和组件内的Hook调用完全无关,无法触发真实逻辑。
  3. Ref赋值时机错误:即使不Mock Hook,初始ref.current为null,组件渲染后才会关联DOM元素,但useInfiniteScroll的useEffect依赖ref,只有ref变化时才会重新执行创建Observer的逻辑。

修复步骤

  1. 移除useInfiniteScroll的Mock:让测试使用真实Hook逻辑,仅Mock全局的IntersectionObserver即可。
  2. 利用组件自身的Ref关联:组件渲染后,ref会自动绑定到scroll-target元素,无需手动赋值。
  3. 直接触发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();
        });
    });
});

额外优化点

  1. 修正数据拼写错误:将原mock中的Stroe改为Store,保持数据一致性。
  2. 简化IntersectionObserverMock:仅保留必要方法,直接保存回调函数方便后续触发。
  3. 添加fetchNextPage的Mock实现:确保Promise正常resolve,避免测试中出现未处理Promise警告。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 04:44:51