post-custom-banner

리액트 테스트 케이스들 중 가장 빈번하고, 세팅이 복잡한 라우팅 테스트 세팅에 관한 정리 글입니다.

세팅

App.tsx를 통해 렌더링되는 페이지 컴포넌트와 달리, 각각의 페이지 컴포넌트들은 react-router-dom 에서 제공하는 라우터 안에 있지 않습니다.

테스트 파일

import { render }from '@testing-library/react'

test('테스트', () => {
    render(<TestPage />)
}

TestPage.tsx

import { useNavigate, useParams } from 'react-router-dom'
 
const TestPage = () => {
	const navigate = useNavigate();
  	const params = useParams()
    /// 생략...
}

따라서 테스트 대상 파일에서 react-router-dom의 훅들을 사용한다면 다음과 같은 에러를 보게 됩니다.

이에 react-router-dom 에서 동작하고 있다는 것을 가정하고 react-router-dom의 훅을 사용하고 있는 컴포넌트를 테스트 할 때에는, App.tsx에서 라우터를 제공했듯이 @testing-library의 render 함수에도 Roter를 Wrapper로써 제공해줘야 합니다.

이 때 테스트의 편의성을 더해주기 위해 현재 pathname, history 등의 정보를 알 수 있도록 커스텀 render 함수를 작성하도록 하겠습니다.

util 함수 작성

import { render as rtlRender, RenderOptions, RenderResult } from '@testing-library/react'

// history 설치 필요
import { createMemoryHistory, MemoryHistory } from 'history'
// react-router-dom v6의 Router 사용
import { Router } from 'react-router-dom'

type CustomRenderOption = {
  // ex) ['/', '/listPage', '/eventPage']
  history?: Array<string>
  historyIndex?: number
  // render 함수의 option 파라미터 수정
  renderOptions?: Omit<RenderOptions, "wrapper">
}
  
// react-testing-library 의 render 리턴 타입에 history 타입 추가
type CustomRenderResult = RenderResult & { history: MemoryHistory }


function render(ui: ReactElement, { history, historyIndex, client, ...renderOptions}: CustomRenderOptions): CustomRenderResult {

  const history = createMemoryHistory({
    initialEntries: history,
    initialIndex: historyIndex
  })

  const Wrapper =({ children }: { children: ReactElement }) => {
    return (
      <Router location={history.location} navigator={history}>
      	// tanstack-query 등 추가로 context가 필요한 경우 이곳에 추가
      	{children}
      </Router>
    )
  }

  const rtlRenderObj = rtlRender(ui, { wrapper: Wrapper, ...renderOptions })

  return { ...rtlRenderObj, history }
}

위와 같이 작성했다면 이제 다음과 같이 편리하게 테스트를 진행할 수 있습니다.

test('테스트', async () => {
    const { history } = render(<IndexPage />, {})
         
    await useEvent.click(screen.getByText('이벤트 페이지로 가기'))        
    
    expect(history.location.pathname).eq('/eventPage')
}

useParams 등의 목킹

위와 같이 useNagate 등으로 페이지 이동에 대한 테스트를 마쳤습니다. 이 외에도 react-router-dom 의 여러 hook 등을 이용한 페이지 이동을 테스트하고자 합니다. useParams hook을 사용한 동적 페이지에 대한 테스트를 진행하겠습니다.

문제점

// App.tsx에 등록된 routes 정보
[
	{ path: '/events/:id', element: <EventDetailPage key={window.location.pathname} />,
]

// EventDetailPage 
const EventDetailPage = () => {
	const { id } = useParams()
  
    return (
    	<article>
      		<h2>이벤트 {id}</h2>
      		// ...중략
      	</article>
    )
}

// test
test('테스트', async () => {
  const EVENT_ID = 123
  const { history } = render(<EventDetailPage />, { history: [`/events/${EVENT_ID}`] })
         
	waitFor(() => expect(screen.getByText('이벤트 123')).not.toBeNull())
}

동적 경로를 통해 id를 부여했고, 페이지에서 useParams를 통해 id를 접근해서 표시하는 간단한 컴포넌트 입니다. 당연히 잘 동작하겠거니 생각했지만, 테스트는 실패합니다.

웬걸? 디버깅을 해보니 id는 undefined로 확인되었습니다.

생각해보니 /events/123 의 123이 id라는 이름의 param 이였다는건 사전 정의한 라우팅 파일에서 /events/:id 와 같이 동적 param 이라는것을 알려주었기 때문입니다.
우리의 세팅에서는 동적 param에 대한 세팅이 없었으니, 테스팅 환경에서는 useParams을 통해 접근할 수 없었겠다는 결론이 나옵니다.

react-router-dom v6. dynamic-segments

render 함수의 wraper에 이 정보를 등록하는 방법도 있겠으나, 불필요한 정보를 많이 넘겨주어야 하고 코드 작성도 번거로우니, 가장 간편하고 다른 hook들의 케이스에도 대응이 가능한 spyOn 함수를 통해 해결해보도록 하겠습니다.

mock, spy

import * as ReactRouter from 'react-router-dom'
import { beforeAll, expect, test, vi } from 'vitest'

// mocking
vi.mock('react-router-dom', async () => ({
  ...((await vi.importActual('react-router-dom'))),
}))

test('2단계 렌더링 확인', async () => {
  // spy
  vi.spyOn(ReactRouter,'useParams').mockReturnValue({ id: "123" })

  render(<EventDetailPage />, {})
})

vitest의 mock 이나 spyOn 등의 함수를 통해 테스트 관심사 밖의 함수나 객체에 가짜 정보를 주입할 수 있습니다. 이렇게하면 테스트 시 외부 api에 실제로 의존하지 않고도 빠르고 편리한 테스팅이 가능합니다.

일반적인 경우에는 spyOn으로도 목킹이 가능하지만, react-router-dom 등 일부 라이브러리의 목킹의 경우 spyOn시에 실패할 때가 있습니다. 이런 경우 vi.mock 함수와의 조합을 통해 해결이 가능합니다. 따라서 상단에 vi.mock을 통해 react-router-dom 자체를 목킹해주고, 각 테스트에서 spyOn 함수를 통해 테스트 케이스별 retunValue등을 설정했습니다.

profile
웹프로그래머
post-custom-banner

0개의 댓글