[React] 使用 jest 進行單元測試的起手式

使用 jest 進行單元測試的起手式

前言


面對複雜的組件邏輯,唯有加上測試保護才可以盡量避免功能異動時誤傷原本正確的邏輯,而透過 create-react-app 建立的專案都會包含測試的套件環境,因此可以讓開發者直接進行測試程式的撰寫;但我們在對單獨的 component / container 等進行測試時總會相依一些外部資源,因此需要 mock 一些外部資源如 api 或 redux state 等方便我們產生所希望的情境來進行測試,而這篇就稍微探索一下測試的起手式。

 

 

環境準備


在使用 create-react-app 建立的專案中,預設會安裝以下與測試相關套件:

  • @testing-library/jest-dom
  • @testing-library/react
  • @testing-library/user-event

另外也會自動在 src 目錄下建立一個 setupTests.js 檔案,有關測試相關全域的設定可以定義在這邊。

 

Mock API

前端最重要的相依資源就是外部 API,因此如果是要測試 container 組件,例如組件在初始時會自動呼叫 API 取得資料呈現在畫面上,所以我們就會希望去指定特定的 API Response 作為測試情境的變因,來檢查畫面是否能夠依照我們定義的邏輯來顯示不同資訊。由於筆者在開發時期本來就有用 msw (Mock Service Worker) 作為 mock API 使用,因此在測試時完全不費吹灰之力,只要透過簡單的設定就搞定了。

首先在 setupTests.js 中定義在測試的生命週期中啟動 msw ,如果有一些固定的 api response 也可以在這邊定義,但實務上因為 api response 都會隨著測案而改變,因此比較不會定義在這邊。

import '@testing-library/jest-dom'
import { setupServer } from 'msw/node'

export const mswServer = setupServer()

beforeAll(() => {
  // 啟動 API mocking
  mswServer.listen()
})

afterEach(() => {
  // 重設 API Handles 避免沿用到其他測案的回應設定
  mswServer.resetHandlers()
})

afterAll(() => {
  // 關閉 API mocking
  mswServer.close()
})

 

在測案中可以載入剛剛定義的 mswServer ,透過 use 方法定義 api 的 handler 來決定在此 test 中這個 api 被呼叫時要 response 怎樣的資料。

import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SampleComponent from './SampleComponent'
import { rest } from 'msw'

test('測試呼叫 API 並把結果顯示在畫面', async () => {
  // Arrange
  mswServer.use(
    rest.post(getApiUrl('/fire-auth/FA01/FA010105'), (req, res, ctx) => {
      const data = { name: '王永慶' }
      const response = generateSuccessResponse('FA010105', data)
      return res(ctx.status(200), ctx.delay(), ctx.json(response))
    }),
  )
  renderWithProviders(<SampleComponent />)

  // Act
  const callApiBtn = screen.getByTestId('callApiBtn')
  userEvent.click(callApiBtn) // 模擬使用者點擊

  // Assert
  expect(await screen.findByText('王永慶')).toBeInTheDocument()
  await waitFor(() => expect(screen.getByTestId('userName')).toHaveTextContent('王永慶'))
})

 

如果沒有定義到的 api handler 也會有提示喔!真貼心!

 

 

Mock Redux State

在 container 組件中也會使用到 Redux 全域狀態,因此我們也希望 mock 它來操作出測試情境所需的初始狀態值;在正常網站應用程式中,會透過 Provider 包在應用程式最上層,讓所有應用程式下的組件都在 Redux 的使用範圍中,但因為單元測試只會針對特定組件進行測試,因此需要自行補上才可以運作,否則會出現以下的錯誤訊息。

could not find react-redux context value; please ensure the component is wrapped in a <Provider>

 

首先依照 Redux Toolkit 官方建議來定義出一個 store 取得方式 initStore,從我們原本定義的 store 加上 preloadedState 參數,讓我們可以透過外部控制初始 state。

import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../slices/userSlice'

// Create the root reducer independently to obtain the RootState type
const rootReducer = combineReducers({
  user: userReducer
})


// Store
export const initStore = preloadedState => {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
  })
}

 

接著定義一個共用的 renderWithProviders 方法,讓測試組件都包裹上 Provider 才能去操作 Redux;這邊產生 store 的方式就是使用到剛剛建立的 initStore 方法,並且可以接受外部傳入特定 slice's state 來取代 init state 進行測試。

import React from 'react'
import { render } from '@testing-library/react'
import { Provider } from 'react-redux'
import PropTypes from 'prop-types'
import { initStore } from '../setup/setupStore'
import { BrowserRouter } from 'react-router-dom'

export function renderWithProviders(
  ui,
  {
    // 傳入特定 slice state 來取代 init state
    preloadedState = {},
    // 當呼叫端不將 store 傳入時,可以自動建立預設 store 來使用
    // 且依照傳入特定 slice 的 state 來取代 init state 進行測試
    store = initStore(preloadedState),
    // 傳入特定 route path 作為初始位置
    route = '/',
    ...renderOptions
  } = {},
) {
  function Wrapper({ children }) {
    return (
      <Provider store={store}>
        <BrowserRouter>{children}</BrowserRouter>
      </Provider>
    )
  }

  Wrapper.propTypes = {
    children: PropTypes.object,
  }

  window.history.pushState({}, 'testing page', route)
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

 

使用方式就是在 test 將原本的 @testing-library/react 提供的 render 改成 renderWithProviders 就搞定了。

import { renderWithProviders } from 'utils/unitTest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SampleComponent from './SampleComponent'

test('操作 component 執行 dispatch 變更 redux 狀態', async () => {
  // Arrange
  // 測案需指定初始 state 時才需要設定 preloadedState
  const initialSpinnerState = { count: 2 }
  const { store } = renderWithProviders(<SampleComponent />, {
    preloadedState: { spinner: initialSpinnerState },
  })
  
  // Act
  const addCounterBtn = screen.getByTestId('addCounterBtn')
  userEvent.click(addCounterBtn) // 模擬使用者點擊

  // Assert
  const spinnerState = store.getState().spinner
  expect(spinnerState.count).toBe(3)
})

 

 

Mock useNavigate

因為 useNavigate 需要當組件在 Route 中才能使用,如果單獨測試的組件內有包含 useNavigate 的操作,則會得到以下的錯誤訊息。

useNavigate() may be used only in the context of a <Router> component.

 

在這邊我只要測試到 useNavigate 有被呼叫到,並且確定轉導的 url 是什麼就好,因此我們去 mock 這個 hook 就好;一樣在 setupTests.js 中去 mock useNavigate hook ,然後將 mockedUseNavigate 傳出來給 test 使用。

export const mockedUseNavigate = jest.fn()

jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: () => mockedUseNavigate,
}))

 

使用方式就是測試剛剛建立的 mockedUseNavigate 有沒有被呼叫並轉導到特定的網址即可。

test('測試 useNavigate 導頁', async () => {
  // Arrange
  renderWithProviders(<SampleComponent />)
  
  // Act
  const goInvestPageBtn = screen.getByTestId('goInvestPageBtn')
  userEvent.click(goInvestPageBtn) // 模擬使用者點擊

  // Assert
  expect(mockedUseNavigate).toHaveBeenCalledWith('/pvt/feature/invest')
})

 

 

測試組件


最簡單嘗試測試就是建立一個測試組件,把我們想要測試的所有情境都逐一加入,最終成為我們的測試完整範例 demo,可以讓新進同仁迅速掌握測試技巧;因此我們簡單依據上述情境建立出個功能出來如下。

  • 測試資料是否存在某區塊
  • prop修改的 render 情境
  • 觸發組件中的點擊事件
  • 單獨測試 redux 中的 dispatch action
  • 透過組件觸發 redux 狀態改變
  • 呼叫 api 將 response 顯示在畫面上
  • 使用 useNavigate 轉址
  • 使用 Link 轉址

 

待測試的 SampleComponent.js 組件如下


import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { increment } from 'slices/spinnerSlice'
import { useFA010105Mutation } from 'services/fa01'
import { Link, useNavigate } from 'react-router-dom'

/**
 *  單元測試使用的範例組件
 */
const SampleComponent = ({
  title,
  onClosed,
}) => {
  const dispatch = useDispatch()
  const navigate = useNavigate()

  const [apiFA010105] = useFA010105Mutation()
  const [userName, setUserName] = useState('')

  const getUserInfo = async () => {
    // call api 測試
    const response = await apiFA010105().unwrap()
    const { header: { returnCode, returnMsg }, body } = response
    if (returnCode.isSuccess()) {
      setUserName(body.name)
    } else {
      setUserName(returnMsg)
    }
  }

  const closeModal = () => {
    // callback 測試
    onClosed()
  }

  const goInvestPage = () => {
    // useNavigate 測試
    navigate('/pvt/feature/invest')
  }

  const addCounter = async () => {
    // redux 測試
    dispatch(increment())
  }

  return (
    <div>
      {/* info */}
      <div data-testid="title">{title}</div>
      <div data-testid="userName"> {userName}</div>

      {/* link */}
      <Link data-testid="goInvestPageByLink" to="/pvt/feature/invest?id=1234">go invest page by link</Link>

      {/* buttons */}
      <button data-testid="closeModalBtn" onClick={closeModal}>close modal</button>
      <button data-testid="addCounterBtn" onClick={addCounter}>add counter</button>
      <button data-testid="goInvestPageBtn" onClick={goInvestPage}>go invest page</button>
      <button data-testid="callApiBtn" onClick={getUserInfo}>call api to get user info</button>
    </div>
  )
}

SampleComponent.propTypes = {
  title: PropTypes.string,
  onClosed: PropTypes.func,
}

export default SampleComponent

 

測試全域設置 setupTests.js 如下

import '@testing-library/jest-dom'
import { setupServer } from 'msw/node'

// ===================================
// 在執行每個 Test Suites 前會被執行一次
// ===================================

// ========
// 必要載入
// ========
import './utils/extensions'
import './setup/setupI18n'

// ========
// MOCK
// ========

window.sessionStorage.setItem('@xsrf_token', 'xxx')
window.sessionStorage.setItem('@auth_token', 'ooo')

// ---

export const mockedUseNavigate = jest.fn()

jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: () => mockedUseNavigate,
}))

// ==========
// LIFE CYCLE
// ==========

export const mswServer = setupServer()

beforeAll(() => {
  // 啟動 API mocking
  mswServer.listen()
})

afterEach(() => {
  // 重設 API Handles 避免沿用到其他測案的回應設定
  mswServer.resetHandlers()
})

afterAll(() => {
  // 關閉 API mocking
  mswServer.close()
})

 

測試使用到的自訂渲染方法  renderWithProviders 如下

import React from 'react'
import { render } from '@testing-library/react'
import { Provider } from 'react-redux'
import PropTypes from 'prop-types'
import { initStore } from '../setup/setupStore'
import { BrowserRouter } from 'react-router-dom'

export function renderWithProviders(
  ui,
  {
    // 傳入特定 slice state 來取代 init state
    preloadedState = {},
    // 當呼叫端不將 store 傳入時,可以自動建立預設 store 來使用
    // 且依照傳入特定 slice 的 state 來取代 init state 進行測試
    store = initStore(preloadedState),
    // 傳入特定 route path 作為初始位置
    route = '/',
    ...renderOptions
  } = {},
) {
  function Wrapper({ children }) {
    return (
      <Provider store={store}>
        <BrowserRouter>{children}</BrowserRouter>
      </Provider>
    )
  }

  Wrapper.propTypes = {
    children: PropTypes.object,
  }

  window.history.pushState({}, 'testing page', route)
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

 

相對應 SampleConponen t組件的測試檔案 SampleConponent.test.js 如下,這邊就不一一解釋了。


import React from 'react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SampleComponent from './SampleComponent'
import { initStore } from 'setup/setupStore'
import { increment } from 'slices/spinnerSlice'
import { rest } from 'msw'
import { renderWithProviders } from 'utils/unitTest'
import { generateSuccessResponse, getApiUrl } from 'mocks/helper'
import { mockedUseNavigate, mswServer } from 'setupTests'

describe('測試範例 SampleComponent 組件', () => {

  // =========================
  // DOM
  // =========================

  test('傳入 props 顯示在對應 DOM 位置', () => {
    // Arrange
    const title = '這個是標題'
    renderWithProviders(<SampleComponent title={title} />)

    // Assert
    expect(screen.getByTestId('title')).toHaveTextContent(title)
  })

  test('傳入 props 顯示在對應 DOM 位置(更新 props 值)', () => {
    // Arrange1
    const { rerender } = renderWithProviders(
      <SampleComponent title="這個是標題1" />,
    )
    // Assert1
    expect(screen.getByTestId('title')).toHaveTextContent('這個是標題1')

    // Arrang2 (更改傳入值)
    rerender(
      <SampleComponent title="這個是標題2" />,
    )
    // Assert2
    expect(screen.getByTestId('title')).toHaveTextContent('這個是標題2')
  })

  // =========================
  // Function
  // =========================

  test('測試 callback function 是否被執行', async () => {
    // Arrange
    const onClosedFn = jest.fn()
    renderWithProviders(<SampleComponent onClosed={onClosedFn} />)

    // Act   
    const closeModalBtn = screen.getByTestId('closeModalBtn')
    // screen.debug(closeModalBtn) // 可將closeModalBtn印出查錯
    userEvent.click(closeModalBtn) // 模擬使用者點擊

    // Assert
    expect(onClosedFn).toBeCalledTimes(1)
  })

  // =========================
  // Navigate
  // =========================

  test('測試 useNavigate 導頁', async () => {
    // Arrange
    renderWithProviders(<SampleComponent />)
    
    // Act
    const goInvestPageBtn = screen.getByTestId('goInvestPageBtn')
    userEvent.click(goInvestPageBtn) // 模擬使用者點擊

    // Assert
    expect(mockedUseNavigate).toHaveBeenCalledWith('/pvt/feature/invest')
  })

  test('測試 link 導頁', async () => {
    // Arrange
    renderWithProviders(<SampleComponent />)
    
    // Act
    const goInvestPageLink = screen.getByTestId('goInvestPageByLink')
    userEvent.click(goInvestPageLink) // 模擬使用者點擊

    // Assert
    const url = new URL(window.location.href)
    expect(url.pathname).toBe('/pvt/feature/invest')
    expect(url.searchParams.has('id')).toBe(true)
    expect(url.searchParams.get('id')).toEqual('1234')
  })

  // =========================
  // Redux Only
  // =========================

  test('操作 store 執行 dispatch 變更 redux 狀態', () => {
    // Arrange
    // 測案需指定初始 state 時才需要設定 preloadedState
    const preloadedState = { spinner: { count: 5 } }
    const store = initStore(preloadedState)

    // Act
    store.dispatch(increment())

    // Assert
    const spinnerState = store.getState().spinner
    expect(spinnerState.count).toBe(6)
  })

  // =========================
  // Redux with Component
  // =========================

  test('操作 component 執行 dispatch 變更 redux 狀態', async () => {
    // Arrange
    // 測案需指定初始 state 時才需要設定 preloadedState
    const initialSpinnerState = { count: 2 }
    const { store } = renderWithProviders(<SampleComponent />, {
      preloadedState: { spinner: initialSpinnerState },
    })
    
    // Act
    const addCounterBtn = screen.getByTestId('addCounterBtn')
    userEvent.click(addCounterBtn) // 模擬使用者點擊

    // Assert
    const spinnerState = store.getState().spinner
    expect(spinnerState.count).toBe(3)
  })

  // =========================
  // API(整合測試)
  // =========================

  test('測試呼叫 API 並把結果顯示在畫面', async () => {
    // Arrange
    mswServer.use(
      rest.post(getApiUrl('/fire-auth/FA01/FA010105'), (req, res, ctx) => {
        const data = { name: '王永慶' }
        const response = generateSuccessResponse('FA010105', data)
        return res(ctx.status(200), ctx.delay(), ctx.json(response))
      }),
    )
    renderWithProviders(<SampleComponent />)

    // Act   
    const callApiBtn = screen.getByTestId('callApiBtn')
    userEvent.click(callApiBtn) // 模擬使用者點擊

    // Assert
    expect(await screen.findByText('王永慶')).toBeInTheDocument()
    await waitFor(() => expect(screen.getByTestId('userName')).toHaveTextContent('王永慶'))
  })
})

 

 

執行


想要獲得更多資訊時,常用語法參數大概如下

  • -- verbose: Display individual test results with the test suite hierarchy.
  • -- collectCoverage: Indicates that test coverage information should be collected and reported in the output.
"scripts": {
    "test": "react-scripts test",
    "test-detail": "react-scripts test --verbose",
    "test-coverage": "react-scripts test --verbose --collectCoverage",
  },

 

執行後就會產生測試結果,如果有失敗也會清楚地說明失敗原因

 

另外,有詳細的 code coverage 網頁版說明可以在專案根目錄下的 coverage 資料夾中取得

 

 

參考資訊


jest cli

jest api

jest-dom

testing-library for react

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !