ในการทดสอบ component นั่น หากใน component มีฟังก์ชั่น หรือ logic อะไรบางอย่างอยู่ใน component นั้นด้วย มันทำให้การทดสอบ component นั่นๆ จะไปเรียกฟังก์ชั่นเหล่านั้น

จากบทความก่อนหน้านี้ https://nutshell.work/how-to-testing-custom-hooks-in-testing-components-with-cypress/

หากเราไม่ต้องการให้มีการทำงานภายในฟังก์ชั่นที่มีการเรียกใช้งาน เราสามารถ Mock ตัว stub/spy ของฟังก์ชั่นเหล่านี้ได้


ปรับวิธีการเรียกใช้งาน Custom Hook

ก่อนอื่นต้องบอกก่อนว่า ถ้าหากเราต้องการที่จะ stub ฟังก์ชั่นใน hook เราไม่สามารถเข้าไป stub มันผ่านทาง component ได้ตรงๆ มันต้องมีการปรับวิธีการเรียกใช้งาน hook ใหม่สักหน่อย ซึ่งต้องบอกก่อนว่า ผมใช้วิธีนี้อยู่ ในอนาคตหากมีวิธีที่ดีกว่า ค่อยว่ากัน

เริ่มต้นที่ เรามี component และ hook ของเราจากบทความที่แล้ว คือ component สำหรับเพิ่มค่าบวก ลบ ซึ่งมีโค๊ดดังนี้

import useCounter from './useCounter'

const Counter = () => {
  const { count, increment, decrement } = useCounter()

  return (
    <div>
      <h1>Count: {count}</h1>
      <button id='incrementButton' onClick={increment}>
        -
      </button>
      <button id='decrementButton' onClick={decrement}>
        +
      </button>
    </div>
  )
}

export default Counter

counter.tsx

import { useCallback, useState } from 'react'

export type UseCounterReturnType = {
  count: number
  increment: () => void
  decrement: () => void
}

const useCounter = (): UseCounterReturnType => {
  const [count, setCount] = useState(0)

  const increment = useCallback(() => setCount((x) => x + 1), [])
  const decrement = useCallback(() => setCount((x) => x - 1), [])

  return {
    count,
    increment,
    decrement
  }
}

export default useCounter

useCounter.tsx

import React from 'react'
import Counter from './counter'

describe('<Counter />', () => {
  it('Stub custom hook', () => {
    cy.mount(<Counter />)

    cy.get('h1').contains('Count: 0')

    cy.get('#incrementButton').click()

    cy.get('h1').contains('Count: 1')
  })
})

counter.cy.tsx

สิ่งที่ต้องทำเลยคือ เราจะต้องทำยังไงก็ได้ ให้ตัว component ไปเรียกใช้งาน useCount ที่เราได้ทำการ Mock ตัว stub/spy มันเอาไว้


สร้าง Stub ของ Hook

เริ่มต้นจากเราต้องสร้างตัว Mock ตัว stub/spy ของ hook ขึ้นมาก่อน ซึ่งเราจะสร้างไว้ที่ counter.cy.tsx

const useCounterStub = () => {
  return {
    count: 0,
    increment: cy.stub().as('increment'),
    decrement: cy.stub().as('decrement'),
  }
}

counter.cy.tsx

จากนั้นก็มาถึงขึ้นตอนที่จะต้องมีการปรับการเรียกใช้งาน hook ใน component แล้ว โดยจากเดิมเป็นการเรียกใช้งานตรงๆ ผ่านการ import ไฟล์แล้วใช้งานเลย

เราจะเปลี่ยนเป็น ส่งค่า hook ผ่านทาง component props แทน แบบนี้

  it('Stub custom hook', () => {
    const useCounterStub = () => {
      return {
        count: 0,
        increment: cy.stub().as('increment'),
        decrement: cy.stub().as('decrement')
      }
    }

    cy.mount(<Counter useCounter={useCounterStub} />)
  })

counter.cy.tsx

ใส่ใน component ก็จะต้องเพิ่ม props ที่ใช้รับค่า useCounter ที่ส่งมาด้วย

import { UseCounterReturnType } from './useCounter'

type CounterProps = {
  useCounter: () => UseCounterReturnType
}

const Counter = ({ useCounter }: CounterProps) => {
  const { count, increment, decrement } = useCounter()

  return (
    <div>
      <h1>Count: {count}</h1>
      <button id='incrementButton' onClick={increment}>
        -
      </button>
      <button id='decrementButton' onClick={decrement}>
        +
      </button>
    </div>
  )
}

export default Counter

counter.tsx

เท่านี้เรา ก็สามารถที่จะ Mock ตัว stub/spy ฟังก์ชั่นต่างๆ ภายใน hooks ได้โดยที่ไม่จำเป็นต้องไปเรียกใช้งานคำสั่งต่างๆ ภายในฟังก์ชันเลย

import React from 'react'
import Counter from './counter'

describe('<Counter />', () => {
  it('Stub custom hook', () => {
    const useCounterStub = () => {
      return {
        count: 0,
        increment: cy.stub().as('increment'),
        decrement: cy.stub().as('decrement')
      }
    }

    cy.mount(<Counter useCounter={useCounterStub} />)

    cy.get('#incrementButton').click()

    cy.get('@increment').should('have.been.called')
  })
})

counter.cy.tsx

run test

กำหนดค่า Default

ถึงแม้ว่า เราจะสามารถ Mock ตัว stub/spy ค่าต่างๆ ภายใน hook ได้แล้ว แต่ว่า ยังมีส่วนที่ต้องเพิ่มเติมเข้าไปอีกหน่อย

นั่นคือ สังเกตุว่า ถ้าหากเราต้องการเรียกใช้งาน component เราจะต้อง ส่ง hook ไปด้วยเสมอ ซึ่งอาจจะเพิ่มความยุ่งยากได้

วิธีแก้คือ ให้กำหนดค่า default ของ parameter ของ useCounter ใน component props ให้ไปให้ useCounter จริงๆ ที่ไม่ใช้ ตัว Mock stub/spy

import _useCounter from './useCounter'

const Counter = ({ useCounter = _useCounter }: CounterProps) => {
  // ...
})

เพิ่ม _useCount เป็นค่า default in counter.tsx

รวม code ทั้งหมด ก็จะได้แบบนี้

import _useCounter, { UseCounterReturnType } from './useCounter'

type CounterProps = {
  useCounter: () => UseCounterReturnType
}

const Counter = ({ useCounter = _useCounter }: CounterProps) => {
  const { count, increment, decrement } = useCounter()

  return (
    <div>
      <h1>Count: {count}</h1>
      <button id='incrementButton' onClick={increment}>
        -
      </button>
      <button id='decrementButton' onClick={decrement}>
        +
      </button>
    </div>
  )
}

export default Counter

counter.tsx

เท่านี้เราก็สามารถเรียกใช้งาน component แบบเดิมได้แล้ว และสามารถสร้าง Mock ตัว stub/spy ได้โดยไม่กระทบการทำงานของ component