🔥 React Query 테스트하기

1051자
13분

React Query를 사용할 때 테스트 관련 질문이 자주 나옵니다. 이에 대한 답변을 여기서 정리해 보겠습니다. 이런 질문이 많이 나오는 이유 중 하나는 '스마트' 컴포넌트(또는 컨테이너 컴포넌트라고도 함)를 테스트하는 것이 쉽지 않기 때문입니다. 훅의 등장으로 이런 구분은 크게 의미가 없어졌습니다. 이제는 임의로 나누고 props를 전달하기보다는 필요한 곳에서 직접 훅을 사용하는 것이 권장됩니다.

이는 코드 가독성과 응집도 측면에서 큰 개선이지만, 이제 더 많은 컴포넌트가 단순히 'props'만이 아닌 외부 의존성을 가지게 되었습니다.

컴포넌트들은 useContext를 사용하거나, useSelector를 사용하거나, useQuery를 사용할 수 있습니다.

이런 컴포넌트들은 더 이상 순수하지 않습니다. 다른 환경에서 호출하면 다른 결과를 얻게 되기 때문입니다. 이들을 테스트할 때는 주변 환경을 세심하게 설정해야 제대로 작동합니다.

네트워크 요청 모킹하기

React Query는 비동기 서버 상태 관리 라이브러리이므로, 여러분의 컴포넌트는 대부분 백엔드로 요청을 보낼 것입니다. 테스트 환경에서는 이 백엔드가 실제로 데이터를 제공할 수 없고, 설령 가능하다 해도 테스트를 백엔드에 의존하게 만들고 싶지 않을 것입니다.

Jest로 데이터를 모킹하는 방법에 대한 글은 인터넷에 많이 있습니다. API 클라이언트가 있다면 그것을 모킹할 수 있고, fetch나 axios를 직접 모킹할 수도 있습니다. Kent C. Dodds가 그의 글 "Stop mocking fetch"에서 말한 것에 전적으로 동의합니다:

@ApiMocking이 만든 mock service worker를 사용하세요.

이는 API를 모킹할 때 단일 진실 공급원이 될 수 있습니다:

  • 테스트를 위한 노드 환경에서 작동합니다.
  • REST와 GraphQL을 지원합니다.
  • useQuery를 사용하는 컴포넌트의 스토리를 작성할 수 있는 스토리북 애드온이 있습니다.
  • 개발 목적으로 브라우저에서 작동하며, 브라우저 개발자 도구에서 나가는 요청을 여전히 볼 수 있습니다.
  • Cypress와 함께 사용할 수 있으며, fixtures와 유사합니다.

네트워크 계층 문제를 해결했으니, 이제 React Query와 관련된 몇 가지 주의할 점에 대해 이야기해 봅시다:

QueryClientProvider

React Query를 사용할 때마다 QueryClientProvider와 queryClient가 필요합니다. queryClient는 QueryCache를 담고 있는 그릇입니다. 캐시는 쿼리의 데이터를 보관합니다.

각 테스트마다 별도의 QueryClientProvider를 사용하고 새로운 QueryClient를 생성하는 것이 좋습니다. 이렇게 하면 테스트들이 완전히 독립적으로 실행됩니다. 다른 방법으로는 각 테스트 후에 캐시를 지우는 방법이 있지만, 테스트 간 공유 상태를 최소화하는 것이 좋습니다. 그렇지 않으면 테스트를 병렬로 실행할 때 예상치 못한 결과나 불안정한 결과를 얻을 수 있습니다.

커스텀 훅을 위한 설정

커스텀 훅을 테스트한다면 react-hooks-testing-library를 사용하고 있을 겁니다. 이는 훅을 테스트하는 가장 쉬운 방법입니다. 이 라이브러리를 사용하면 wrapper로 훅을 감쌀 수 있습니다. wrapper는 테스트 컴포넌트를 렌더링할 때 감싸는 React 컴포넌트입니다. 이는 각 테스트마다 실행되므로 QueryClient를 만들기에 완벽한 장소입니다:

const createWrapper = () => {
  // ✅ 각 테스트마다 새로운 QueryClient를 만듭니다
  const queryClient = new QueryClient()
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
 
test('my first test', async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper(),
  })
})
 
javascript

컴포넌트를 위한 설정

useQuery 훅을 사용하는 컴포넌트를 테스트하려면 해당 컴포넌트도 QueryClientProvider로 감싸야 합니다. react-testing-libraryrender 함수를 감싸는 작은 래퍼를 만드는 것이 좋은 선택입니다. React Query가 내부적으로 테스트에 사용하는 방법을 참고해 보세요.

재시도 기능 끄기

React Query와 테스트에서 가장 흔한 '함정' 중 하나는 라이브러리가 기본적으로 지수 백오프를 사용해 세 번 재시도한다는 점입니다. 이로 인해 오류가 발생하는 쿼리를 테스트하려 할 때 테스트가 타임아웃될 가능성이 높습니다. 가장 쉬운 재시도 기능 끄는 방법은 다시 한 번 QueryClientProvider를 통해서입니다. 위의 예제를 확장해 보겠습니다:

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        // ✅ 재시도를 끕니다
        retry: false,
      },
    },
  })
 
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
 
test("my first test", async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })
}
 
javascript

이렇게 하면 컴포넌트 트리의 모든 쿼리에 대해 '재시도 없음'으로 기본값을 설정합니다. 다만 이는 실제 useQuery에 명시적으로 재시도 횟수가 설정되어 있지 않을 때만 작동한다는 점을 알아야 합니다. 5번 재시도하도록 설정된 쿼리가 있다면, 이는 여전히 우선권을 가집니다. 기본값은 말 그대로 기본값으로만 사용되기 때문입니다.

setQueryDefaults

이 문제에 대해 제가 드릴 수 있는 가장 좋은 조언은 이런 옵션을 useQuery에 직접 설정하지 말라는 것입니다. 가능한 한 기본값을 사용하고 오버라이드하세요. 특정 쿼리에 대해 정말로 무언가를 변경해야 한다면 queryClient.setQueryDefaults를 사용하세요.

예를 들어, useQuery에 retry를 설정하는 대신:

const queryClient = new QueryClient()
 
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}
 
function Example() {
  // 🚨 테스트에서 이 설정을 오버라이드할 수 없습니다!
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    retry: 5,
  })
}
 
javascript

다음과 같이 설정하세요:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
    },
  },
})
 
// ✅ todos만 5번 재시도합니다
queryClient.setQueryDefaults(['todos'], { retry: 5 })
 
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}
 
javascript

이렇게 하면 모든 쿼리는 두 번 재시도하고, 'todos' 쿼리만 다섯 번 재시도합니다. 그리고 여전히 테스트에서 모든 쿼리에 대해 재시도를 끌 수 있는 옵션을 가집니다.

ReactQueryConfigProvider

물론 이는 알려진 쿼리 키에 대해서만 작동합니다. 때로는 컴포넌트 트리의 일부에 대해서만 설정을 지정하고 싶을 수 있습니다. v2에서 React Query는 이를 위해 ReactQueryConfigProvider를 제공했습니다. v3에서는 몇 줄의 코드로 같은 기능을 구현할 수 있습니다:

const ReactQueryConfigProvider = ({ children, defaultOptions }) => {
  const client = useQueryClient()
  const [newClient] = React.useState(
    () =>
      new QueryClient({
        queryCache: client.getQueryCache(),
        muationCache: client.getMutationCache(),
        defaultOptions,
      })
  )
 
  return (
    <QueryClientProvider client={newClient}>
      {children}
    </QueryClientProvider>
  )
}
 
javascript

이 코드가 실제로 작동하는 모습은 이 코드샌드박스 예제에서 확인할 수 있습니다.

항상 쿼리를 기다리세요

React Query는 본질적으로 비동기이기 때문에 훅을 실행할 때 즉시 결과를 얻을 수 없습니다. 보통은 로딩 상태이고 확인할 데이터가 없을 것입니다. react-hooks-testing-library의 비동기 유틸리티는 이 문제를 해결할 많은 방법을 제공합니다. 가장 간단한 경우, 쿼리가 성공 상태로 전환될 때까지 기다릴 수 있습니다:

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  })
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
 
test("my first test", async () => {
  const { result, waitFor } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })
 
  // ✅ 쿼리가 성공 상태로 전환될 때까지 기다립니다
  await waitFor(() => result.current.isSuccess)
 
  expect(result.current.data).toBeDefined()
}
 
javascript

업데이트

@testing-library/react v13.1.0에서는 사용할 수 있는 새로운 renderHook이 추가되었습니다. 하지만 이는 자체 waitFor 유틸리티를 반환하지 않으므로 @testing-library/react에서 가져올 수 있는 waitFor를 대신 사용해야 합니다. API가 약간 다르며, 불리언 값을 반환하는 것을 허용하지 않고 대신 Promise를 기대합니다. 따라서 코드를 약간 수정해야 합니다:

import { waitFor, renderHook } from '@testing-library/react'
 
test("my first test", async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper()
  })
 
  // ✅ waitFor에 Promise를 반환하도록 expect를 사용합니다
  await waitFor(() => expect(result.current.isSuccess).toBe(true))
 
  expect(result.current.data).toBeDefined()
}
 
javascript

모든 것을 종합하기

이 모든 내용을 하나로 모아 빠르게 구현한 저장소가 있습니다. mock-service-worker, react-testing-library, 그리고 앞서 언급한 래퍼가 모두 포함되어 있습니다. 커스텀 훅과 컴포넌트에 대한 기본적인 실패 및 성공 테스트 네 가지가 포함되어 있습니다. 다음 링크에서 확인할 수 있습니다: https://github.com/TkDodo/testing-react-query