Тестирование компонентов с useQuery

Тестирование компонентов, использующих useQuery, требует воспроизводимого окружения, в котором управление состоянием кэша, запросами и жизненным циклом React не зависит от реального сетевого слоя. Основная цель — изолировать поведение компонента от внешних факторов и обеспечить контроль над состоянием Query Client.

Ключевой элемент инфраструктуры — QueryClient, который создаётся отдельно для каждого теста. Это позволяет избежать утечек состояния между тестами и гарантирует предсказуемость поведения.

Типовая настройка тестового обёртывания компонента выглядит следующим образом:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: 0,
      },
    },
  })
}

function renderWithClient(ui) {
  const queryClient = createTestQueryClient()

  function Wrapper({ children }) {
    return (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    )
  }

  return {
    ...render(ui, { wrapper: Wrapper }),
    queryClient,
  }
}

Отключение повторных попыток (retry: false) и уменьшение времени хранения кэша (gcTime: 0) упрощает тестирование, исключая фоновые эффекты, которые могут изменять состояние между ассерциями.


Тестирование успешной загрузки данных

Основной сценарий useQuery — получение данных и их отображение. Для тестирования важно контролировать функцию запроса (queryFn), заменяя её на мок.

import { screen, waitFor } from '@testing-library/react'
import { useQuery } from '@tanstack/react-query'

async function fetchUser() {
  return { id: 1, name: 'Alex' }
}

function User() {
  const { data, isLoading } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  })

  if (isLoading) return <div>Loading</div>

  return <div>{data.name}</div>
}

Тест:

test('отображает данные пользователя после загрузки', async () => {
  const { getByText } = renderWithClient(<User />)

  expect(getByText('Loading')).toBeInTheDocument()

  await waitFor(() => {
    expect(getByText('Alex')).toBeInTheDocument()
  })
})

Здесь ключевым является ожидание асинхронного обновления состояния. waitFor используется для синхронизации с завершением queryFn.


Мокирование queryFn

Вместо реальных запросов обычно применяется явное мокирование, позволяющее моделировать разные сценарии: успешный ответ, задержку, ошибку.

const mockFetchUser = jest.fn().mockResolvedValue({
  id: 1,
  name: 'Alex',
})

Использование:

useQuery({
  queryKey: ['user'],
  queryFn: mockFetchUser,
})

Такой подход позволяет проверять:

  • количество вызовов queryFn
  • параметры запроса
  • повторное использование кэша

Пример проверки вызова:

expect(mockFetchUser).toHaveBeenCalledTimes(1)

Проверка состояния загрузки

useQuery предоставляет несколько флагов состояния: isLoading, isFetching, isPending (в зависимости от версии).

Различие между ними важно при тестировании UI.

function User() {
  const { data, isFetching } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  })

  return (
    <div>
      {isFetching && <span>Загрузка...</span>}
      {data && <span>{data.name}</span>}
    </div>
  )
}

Тест проверяет как начальное состояние, так и переход после завершения запроса:

test('показывает состояние загрузки и затем данные', async () => {
  const { getByText, queryByText } = renderWithClient(<User />)

  expect(getByText('Загрузка...')).toBeInTheDocument()

  await waitFor(() => {
    expect(queryByText('Загрузка...')).not.toBeInTheDocument()
  })
})

Тестирование ошибок запроса

Ошибочные сценарии являются обязательной частью тестирования React Query компонентов. Для их симуляции queryFn должен выбрасывать исключение.

const mockErrorFetch = jest.fn().mockRejectedValue(
  new Error('Network Error')
)

Компонент:

function User() {
  const { error, isError } = useQuery({
    queryKey: ['user'],
    queryFn: mockErrorFetch,
  })

  if (isError) return <div>{error.message}</div>

  return <div>OK</div>
}

Тест:

test('отображает ошибку при сбое запроса', async () => {
  const { getByText } = renderWithClient(<User />)

  await waitFor(() => {
    expect(getByText('Network Error')).toBeInTheDocument()
  })
})

Важно учитывать, что React Query переводит состояние в error асинхронно, поэтому синхронные проверки без ожидания часто приводят к нестабильным тестам.


Контроль кэша между тестами

Одной из частых проблем является утечка кэша между тестами. QueryClient хранит результаты запросов, поэтому без изоляции тесты становятся зависимыми друг от друга.

Решение — создание нового клиента для каждого теста и явная очистка:

afterEach(() => {
  queryClient.clear()
})

Дополнительно используется:

queryClient.removeQueries()

или

queryClient.resetQueries()

в зависимости от сценария.


Тестирование повторного использования данных из кэша

React Query может возвращать данные из кэша без повторного вызова queryFn. Это поведение важно проверять, особенно при оптимизации производительности.

test('использует кэш при повторном рендере', async () => {
  const { rerender } = renderWithClient(<User />)

  await waitFor(() => {
    expect(mockFetchUser).toHaveBeenCalledTimes(1)
  })

  rerender(<User />)

  expect(mockFetchUser).toHaveBeenCalledTimes(1)
})

Если кэш настроен корректно, повторный рендер не вызывает новый запрос.


Работа с staleTime и повторными запросами

Параметр staleTime влияет на момент, когда данные считаются устаревшими.

useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 5000,
})

При тестировании важно учитывать, что истечение времени может привести к повторным запросам.

Используется управление таймерами:

jest.useFakeTimers()

jest.advanceTimersByTime(6000)

Тест проверяет, что после истечения staleTime происходит новый запрос:

expect(mockFetchUser).toHaveBeenCalledTimes(2)

Предзагрузка данных через QueryClient

Иногда данные уже находятся в кэше до рендера компонента. Это особенно важно при тестировании серверного рендера или предварительных загрузок.

queryClient.setQueryData(['user'], { id: 1, name: 'Alex' })

Компонент сразу получает данные без состояния загрузки:

test('использует предварительно загруженные данные', () => {
  queryClient.setQueryData(['user'], { id: 1, name: 'Alex' })

  const { queryByText } = renderWithClient(<User />)

  expect(queryByText('Loading')).not.toBeInTheDocument()
  expect(queryByText('Alex')).toBeInTheDocument()
})

Тестирование refetch и ручного обновления

useQuery предоставляет функции для принудительного обновления данных.

function User() {
  const { data, refetch } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  })

  return (
    <div>
      <div>{data?.name}</div>
      <button onCl ick={() => refetch()}>Reload</button>
    </div>
  )
}

Тестирование:

test('выполняет повторный запрос при refetch', async () => {
  const { getByText } = renderWithClient(<User />)

  await waitFor(() => {
    expect(mockFetchUser).toHaveBeenCalledTimes(1)
  })

  getByText('Reload').click()

  await waitFor(() => {
    expect(mockFetchUser).toHaveBeenCalledTimes(2)
  })
})

Изоляция QueryClient в сложных сценариях

При тестировании нескольких компонентов, использующих одинаковые ключи запросов, важно учитывать пересечение кэша. В таких случаях применяются:

  • уникальные queryKey для тестов
  • отдельные QueryClient на каждый тест
  • сброс состояния между рендерами
const queryClient = new QueryClient()

afterEach(() => {
  queryClient.getQueryCache().clear()
})

Дополнительно полезно использовать детерминированные ключи:

['user', testId]

Проверка состояния fetching при фоновых обновлениях

React Query может выполнять фоновый refetch при фокусе окна или монтировании.

Для тестирования используется имитация событий:

window.dispatchEvent(new Event('focus'))

И проверка изменения состояния:

await waitFor(() => {
  expect(mockFetchUser).toHaveBeenCalledTimes(2)
})

Контроль таких сценариев требует отключения некоторых дефолтных поведений:

refetchOnWindowFocus: false

Работа с Testing Library и асинхронностью React Query

Основная сложность тестирования useQuery заключается в асинхронной природе обновления состояния React Query. Простые getBy* методы часто недостаточны без ожидания обновлений.

Используются:

  • findBy* для ожидания появления элемента
  • waitFor для проверки состояния
  • act для принудительной синхронизации
await screen.findByText('Alex')

или

await waitFor(() => {
  expect(screen.getByText('Alex')).toBeInTheDocument()
})

Корректное использование этих инструментов устраняет флаки-тесты и делает поведение компонентов воспроизводимым.