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

如何在Cypress中Mock NextJS的useSearchParams钩子?

在Cypress组件测试中Mock NextJS useSearchParams 钩子的方案

我开发了一个依赖NextJS useSearchParams 钩子的Analytics组件,编写Cypress组件测试时,无法Mock该钩子导致测试报错。这个场景类似React Testing Library中Mock useSearchParams 的情况,但我没有使用React Router,现在需要找到在Cypress中Mock该钩子的可行方案。


组件代码

import { costCalculationApiUrl, solarAnalyticsApiHeader } from "@/constants/urls"
import { fetcher } from "@/utils/fetcher"
import { useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux"
import useSWRMutation from "swr/mutation"

import { RootState } from "@/redux/store"

import { CardDescription, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"

import { CustomInstallationReportPreview } from "../../export-report/custom/components"
import { Invoice } from "../../offer/components"
import { Roof, updatePanelPlacementData } from "../../panel-installation/cutsom/state/slice"
import { format2APIDate } from "../helpers"
import { updateFinancialInsightsData } from "../state/slice"
import { AnalyticsForm } from "./form"
import { InvestementCard } from "./investement-card"
import { AnalyticsStats } from "./stats"

export const Analytics = () => {
  const searchParams = useSearchParams()

  const dispatch = useDispatch()

  const nominalPower = searchParams.get("wp")

  const selectedRoofs: Roof[] | undefined = useSelector((state: RootState) => state.customInstallation.selectedRoofs)

  const {
    feedInPrice,
    currentPrice,
    installationDate,
    maintanance,
    insurance,
    interest,
    creditPeriod,
    outsideCapital,
    inflation,
  } = useSelector((state: RootState) => state.financialInsights.formData)

  const { data: customInstallationData } = useSelector((state: RootState) => state.customInstallation)

  const url = `${costCalculationApiUrl}?feed_in_price=${feedInPrice}&used_price=${currentPrice}&installation_date=${format2APIDate(installationDate)}&maintenance_cost_kwp=${maintanance}&insurance_cost_kwp=${insurance}&interest_rate=${interest}&credit_period=${creditPeriod}&wp=${nominalPower}&outside_capital=${outsideCapital}&inflation=${inflation}&additional_grid_feed_in_annual=${customInstallationData?.additionalGridFeedAnnual}&additional_solar_consumption_annual_abs=${customInstallationData?.additionalSolarConsumptionAnnual}&additional_panels=${customInstallationData?.additionalPanels}`

  const { data, error, trigger, reset } = useSWRMutation(url, (url) => fetcher(url, solarAnalyticsApiHeader))

  useEffect(() => {
    if (customInstallationData) {
      reset()
      trigger().then((data) => {
        if (data) {
          const amortizationTime = data["Amortization Time"]
          const amortizationDate = data["Amortization Date"]
          const financialSaving = data["Accumulated Discounted period Cashflows after 20 years"]
          const investment = data["Investment"]
          const timeSeries = data["Time-Series"]

          // Update the state for the report
          // Though we pass data as prop to the components but the report have to access the state to display the data
          dispatch(
            updateFinancialInsightsData({
              amortizationDate,
              amortizationTime,
              financialSaving,
              investment,
              timeSeries,
            })
          )
        }
      })
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [nominalPower, customInstallationData])

  // This effect is for the report
  // When the user access directly to the Analytics tab,
  // The panel placement is empty by default and it should be filled by selected roofs
  // The selected roofs is filled by roofs from the API after the first render of panel installation page
  useEffect(() => {
    dispatch(updatePanelPlacementData(selectedRoofs))
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectedRoofs])

  if (error) {
    // Message: Something went wrong
    return <p>Etwas ist schief gelaufen</p>
  }

  if (!data) {
    return (
      <div className='flex flex-col justify-between gap-8 h-full px-8'>
        <Skeleton className='h-24 w-full' />
        <div className='flex w-full gap-8'>
          <Skeleton className='h-96 w-1/4' />
          <div className='flex flex-col w-3/4 h-fit gap-8'>
            <Skeleton className='w-full h-24' />
            <Skeleton className='w-full h-48' />
            <Skeleton className='w-full h-96' />
          </div>
        </div>
      </div>
    )
  } else {
    return (
      <div className='flex flex-col px-8'>
        <CardTitle>Wirtschaftlichkeitsanalyse</CardTitle>
        <CardDescription className='mt-1 mb-0 text-base'>
          Prüfen Sie Ihre Annahmen und spielen Sie Szenarien durch.
        </CardDescription>
        <div className='flex items-center gap-2 absolute right-6'>
          <Invoice />
          <CustomInstallationReportPreview />
        </div>
        <Separator className='my-4' />
        <div className='flex gap-16 mt-4 w-full items-stretch h-full overflow-hidden'>
          <div className='flex gap-8 w-full'>
            <div className='max-w-[480px] w-full'>
              <AnalyticsForm />
            </div>
            <div className='flex flex-col flex-grow content-stretch w-full gap-8 overflow-auto'>
              <InvestementCard data={data} />
              <Separator />
              <AnalyticsStats data={data} />
            </div>
          </div>
        </div>
      </div>
    )
  }
}

测试代码

import { costCalculationApiUrl } from "@/constants/urls"
import { jest } from "@jest/globals"
import { NextRouterMock } from "cypress/mocks/NextRouterMock"
import { GlobalLayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime"
import { Provider, useSelector } from "react-redux"

import { RootState, store } from "@/redux/store"

import { Analytics } from "../../components"
import { format2APIDate } from "../../helpers"

describe("", () => {
  it("should load data successfully without encountering a 422 error", () => {
    // Mount your component
    cy.mount(
      <Provider store={store}>
        <Analytics />
      </Provider>
    )
  })
})

报错截图

报错截图


解决方案

1. 直接Mock useSearchParams 导出

在测试用例中直接替换next/navigation模块的useSearchParams方法,返回预设的参数对象:

// 在测试用例执行前添加Mock
cy.stub(require('next/navigation'), 'useSearchParams').returns(new URLSearchParams('wp=5000'))

2. 通过Next.js上下文提供Mock数据

useSearchParams本质是从Next.js的GlobalLayoutRouterContext中获取数据,手动提供这个上下文并传入Mock的Router对象,更贴近真实运行环境:

import { GlobalLayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime"

describe("Analytics组件测试", () => {
  it("应成功加载数据且无422错误", () => {
    // 构建包含组件所需参数的searchParams
    const mockSearchParams = new URLSearchParams('wp=5000')
    
    // 提供完整的Router上下文,避免其他导航钩子报错
    cy.mount(
      <Provider store={store}>
        <GlobalLayoutRouterContext.Provider value={{
          searchParams: mockSearchParams,
          push: cy.stub(),
          replace: cy.stub(),
          refresh: cy.stub(),
          prefetch: cy.stub(),
        }}>
          <Analytics />
        </GlobalLayoutRouterContext.Provider>
      </Provider>
    )

    // 验证组件未报错
    cy.get('p').should('not.contain', 'Etwas ist schief gelaufen')
  })
})

3. 补充Mock API请求(可选)

如果组件依赖SWR发起API请求,需要拦截请求并返回Mock数据,避免真实网络请求干扰测试:

// 在组件mount前添加请求拦截
cy.intercept('GET', costCalculationApiUrl, {
  statusCode: 200,
  body: {
    "Amortization Time": 10,
    "Amortization Date": "2034-01-01",
    "Accumulated Discounted period Cashflows after 20 years": 50000,
    "Investment": 30000,
    "Time-Series": []
  }
}).as('getAnalyticsData')

内容的提问来源于stack exchange,提问作者Ala Eddine Menai

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.28 12:35:54