เวลาที่เราใช้ NextJS บ่อยครั้งที่จะต้องมีการใช้ useRouter ที่เป็นของ next/navigation เพื่อทำอะไรบ้างอย่าง

ทีนี้เมื่อเราต้องการเขียน component testing เพื่อที่ทดสอบ component ที่มี useRouter ประกอบอยู่ข้างในนั้นด้วยแล้ว cypress มันจะแสดงผล error ออกมาแบบนี้

ก่อนอื่นต้องทำความเข้าใจก่อนว่า library บางตัวที่เราใช้งานอยู่นั้นมันอยู่ในรูปแบบของ context ซึ่งมันจะมีองค์ประกอบ 2 ส่วนใหญ่ๆ คือ Provider กับ Consumer

useRouter ของ next/navigation ก็เป็นเช่นเดียวกัน ซึ่งปกติแล้ว nextjs มันจะ build in provider มาให้ตั้งแต่ต้นแล้ว เราจึงสามารถเรียกใช้งาน useRouter ที่เป็นตัว consumer ได้เลยทันที (หาอ่านเรื่องของ react context เพิ่มเติมได้)

ทีนี้ เมื่อเราต้องการที่จะทดสอบแค่ component ย่อยๆ มันก็เลยมีแค่การเรียกใช้ useRouter ที่เป็นตัว consumer เลยโดยที่จะยังไม่ได้ mount ตัว provider ของ next/navigation มันเลย เตือน Error นี้ขึ้นมา

provider ไม่ได้ถูก mount ไว้

การ Mock Router ขึ้นมา

โดยทั่วไปแล้ว วิธีการแก้ไข้ที่ง่ายที่สุด คือ การสร้างตัว Mock Router ของ next/navigation ขึ้นมา เพื่อให้ component ที่ต้องการทดสอบสามารถเรียกใช้งานได้

ตัวอย่าง

สมมติว่าเรามี component ที่มีการ เรียกใช้งาน useRouter เพื่อเปลี่ยนไปยังหน้า about จะได้ code ประมาณนี้ (โค๊ดตัวอย่าง ใช้ Next.js เวอร์ชั่น 14.1.0)

Component

import React from 'react'
import { useRouter } from 'next/navigation'

type Props = {
  link: string
  children: React.ReactNode
}

const CustomLink = ({ children, link }: Props) => {
  const router = useRouter()

  const onClick = () => {
    router.push(link)
  }

  return <button onClick={onClick}>{children}</button>
}

export default CustomLink

custom-link.tsx

import CustomLink from './custom-link'

const View = () => {
  return (
    <div>
      <h1>Example</h1>
      <p>This is an example page</p>
      <CustomLink link='/about'>About</CustomLink>
    </div>
  )
}

export default View

view.tsx


สร้าง mock useRouter

เริ่มต้นจากไปสร้างไฟล์ router.js ในโฟลเดอร์ cypress/utils/router.js ซึ่งผมเลือกใช้ JavaScript แทน TypeScript จะได้ไม่ต้อง วุ่นวายกับ Type

import {
  AppRouterContext,
  AppRouterInstance
} from 'next/dist/shared/lib/app-router-context.shared-runtime'

const createRouter = (params) => ({
  route: '/',
  pathname: '/',
  query: {},
  asPath: '/',
  basePath: '',
  back: cy.spy().as('back'),
  beforePopState: cy.spy().as('beforePopState'),
  forward: cy.spy().as('forward'),
  prefetch: cy.stub().as('prefetch').resolves(),
  push: cy.spy().as('push'),
  reload: cy.spy().as('reload'),
  replace: cy.spy().as('replace'),
  events: {
    emit: cy.spy().as('emit'),
    off: cy.spy().as('off'),
    on: cy.spy().as('on')
  },
  isFallback: false,
  isLocaleDomain: false,
  isReady: true,
  defaultLocale: 'fr',
  domainLocales: [],
  isPreview: false,
  ...params
})

const MockRouter = ({ children, ...props }) => {
  const router = createRouter(props)

  return (
    <AppRouterContext.Provider value={router}>
      {children}
    </AppRouterContext.Provider>
  )
}

export default MockRouter

cypress/utils/router.js


วิธีใช้งาน

เมื่อเรามีไฟล์ mock router แล้ว เราสามารถเรียกมันมาใช้งานได้ง่าย โดยเอา MockRouter การครอบ component ที่เราต้องการทดสอบไว้

import CustomLink from './custom-link'
import MockRouter from '../../../cypress/utils/router'

describe('<CustomLink />', () => {
  it('renders', () => {
    cy.mount(
      <MockRouter>
        <CustomLink link='/about'>About</CustomLink>
      </MockRouter>
    )
  })
})

custom-link.cy.tsx

ถ้าหากเราต้องการตรวจสอบว่ามีการเรียก router.push(link) จริงไหม สามารถทำได้โดยการใช้ @Alias ที่กำหนดไว้ push: cy.spy().as('push') ใน cypress/utils/router.js

import CustomLink from './custom-link'
import MockRouter from '../../../cypress/utils/router'

describe('<CustomLink />', () => {
  it('renders', () => {
    cy.mount(
      <MockRouter>
        <CustomLink link='/about'>About</CustomLink>
      </MockRouter>
    )

    cy.get('button').click()

    cy.get('@push').should('be.calledWith', '/about')
  })
})

custom-link.cy.tsx

เท่านี้เราก็สามารถทดสอบ component นี้ได้แล้ว