리액트 테스트 케이스들 중 가장 빈번하고, 세팅이 복잡한 라우팅 테스트 세팅에 관한 정리 글입니다.
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 함수를 작성하도록 하겠습니다.
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')
}
위와 같이 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 함수를 통해 해결해보도록 하겠습니다.
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등을 설정했습니다.