Моки для QueryClient

При тестировании приложений с использованием TanStack Query основной сложностью становится контроль состояния запросов, кэша, повторных запросов, ошибок и асинхронного поведения. Реальный QueryClient управляет внутренним кэшем, таймерами, retry-механизмами, подписками и автоматическими обновлениями. В тестах подобное поведение часто мешает изоляции сценариев.

Моки для QueryClient позволяют:

  • полностью контролировать состояние запросов;
  • отключать побочные эффекты;
  • изолировать тесты друг от друга;
  • эмулировать серверные ответы;
  • проверять состояние кэша;
  • ускорять тестирование;
  • избегать сетевых запросов;
  • воспроизводить ошибки и нестабильные сценарии.

Без мокирования тесты начинают зависеть от времени выполнения, интервалов обновления и случайных состояний кэша.


Что представляет собой QueryClient

QueryClient — центральный объект управления состоянием запросов.

Пример стандартного создания:

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient()

Внутри объекта находятся:

  • Query Cache;
  • Mutation Cache;
  • глобальные настройки;
  • retry-механизмы;
  • логика garbage collection;
  • состояние запросов;
  • подписки на изменения.

Во время тестирования почти никогда не используется production-конфигурация клиента.


Почему нельзя использовать один QueryClient во всех тестах

Распространённая ошибка:

const queryClient = new QueryClient()

test('test A', () => {
  render(...)
})

test('test B', () => {
  render(...)
})

Проблема заключается в том, что кэш между тестами сохраняется.

Последствия:

  • данные одного теста влияют на другой;
  • stale state остаётся в памяти;
  • invalidateQueries затрагивает соседние тесты;
  • мутации изменяют общий cache state;
  • тесты начинают падать случайным образом.

Правильный подход — создавать новый экземпляр для каждого теста.


Базовая фабрика QueryClient для тестов

Наиболее распространённая практика — создание helper-функции.

import { QueryClient } from '@tanstack/react-query'

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

Почему отключают retry:

retry: false

По умолчанию TanStack Query повторяет запросы при ошибке.

Это создаёт проблемы:

  • тесты становятся медленнее;
  • ошибки появляются не сразу;
  • возникают таймеры;
  • асинхронность усложняется.

QueryClientProvider в тестах

Большинство React-компонентов требуют QueryClientProvider.

Стандартный helper:

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

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

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  )
}

Теперь каждый тест получает изолированный cache state.


Полное отключение логов в тестах

TanStack Query выводит ошибки в консоль.

Во время тестирования это создаёт шум.

Пример отключения:

import { QueryClient } from '@tanstack/react-query'

export function createTestQueryClient() {
  return new QueryClient({
    logger: {
      log: () => {},
      warn: () => {},
      error: () => {},
    },
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  })
}

Особенно важно при тестировании ошибок.


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

Наиболее распространённый вариант — мокирование самого fetcher.

Пример:

const getUsers = vi.fn()

getUsers.mockResolvedValue([
  { id: 1, name: 'Alex' },
])

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

useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
})

Преимущества:

  • полная изоляция;
  • отсутствие HTTP;
  • контроль ошибок;
  • контроль времени ответа;
  • проверка аргументов.

Мокирование ошибок

Тестирование error-state критически важно.

Пример:

const getUsers = vi.fn()

getUsers.mockRejectedValue(
  new Error('Server Error')
)

Проверка:

expect(screen.getByText(/server error/i))
  .toBeInTheDocument()

Мокирование loading-state

Иногда требуется бесконечный pending-запрос.

Пример:

const getUsers = vi.fn(
  () => new Promise(() => {})
)

Компонент остаётся в состоянии загрузки.


Предзаполнение кэша через setQueryData

Один из самых мощных способов мокирования — прямое заполнение cache state.

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

Теперь компонент получает данные мгновенно без запроса.


Проверка кэша после мутаций

QueryClient позволяет проверять итоговое состояние.

const users = queryClient.getQueryData(['users'])

expect(users).toEqual([
  { id: 1, name: 'Alex' },
])

Подобный подход полезен при тестировании:

  • optimistic updates;
  • invalidateQueries;
  • cache synchronization;
  • updateQueryData.

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

Иногда необходимо проверить вызов invalidation.

Пример:

const invalidateQueries = vi.spyOn(
  queryClient,
  'invalidateQueries'
)

Проверка:

expect(invalidateQueries)
  .toHaveBeenCalledWith({
    queryKey: ['users'],
  })

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

Аналогично:

const refetchQueries = vi.spyOn(
  queryClient,
  'refetchQueries'
)

Проверка:

expect(refetchQueries)
  .toHaveBeenCalled()

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

При очистке состояния:

const removeQueries = vi.spyOn(
  queryClient,
  'removeQueries'
)

Особенно важно для logout-сценариев.


Очистка QueryClient после тестов

Даже при создании нового клиента желательно очищать cache.

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

Это удаляет:

  • query cache;
  • mutation cache;
  • observers;
  • subscriptions.

Использование gcTime в тестах

Garbage collection может вызывать нестабильность.

Обычно в тестах устанавливают:

gcTime: Infinity

Пример:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: Infinity,
    },
  },
})

Отключение refetchOnWindowFocus

В браузерной среде тестов иногда происходят неожиданные refetch.

Причина:

refetchOnWindowFocus: true

В тестах обычно отключают:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
    },
  },
})

Отключение network reconnect refetch

Аналогичная проблема:

refetchOnReconnect: false

Отключение interval polling

Если приложение использует polling:

refetchInterval: 5000

Тесты начинают зависеть от таймеров.

Решение:

refetchInterval: false

Полная конфигурация тестового QueryClient

Практический production-grade helper:

import { QueryClient } from '@tanstack/react-query'

export function createTestQueryClient() {
  return new QueryClient({
    logger: {
      log: () => {},
      warn: () => {},
      error: () => {},
    },

    defaultOptions: {
      queries: {
        retry: false,
        gcTime: Infinity,
        staleTime: Infinity,
        refetchOnWindowFocus: false,
        refetchOnReconnect: false,
        refetchInterval: false,
      },

      mutations: {
        retry: false,
      },
    },
  })
}

staleTime в тестах

Если данные мгновенно становятся stale:

staleTime: 0

TanStack Query может инициировать повторные запросы.

Для стабильности тестов обычно используют:

staleTime: Infinity

Мокирование useQuery через vi.mock

Иногда мокируется сам hook.

Пример:

vi.mock('@tanstack/react-query', async () => {
  const actual = await vi.importActual(
    '@tanstack/react-query'
  )

  return {
    ...actual,

    useQuery: () => ({
      data: [
        { id: 1, name: 'Alex' },
      ],

      isLoading: false,
      isError: false,
      error: null,
    }),
  }
})

Подход используется редко.

Недостатки:

  • тестируется не реальное поведение;
  • теряется интеграция с cache;
  • invalidateQueries не работает;
  • невозможно проверить lifecycle query.

Когда мокировать useQuery напрямую

Подобный подход допустим:

  • при unit-тестировании UI;
  • при изоляции сложных компонентов;
  • при snapshot-тестах;
  • при отсутствии интереса к QueryClient.

Почему integration-тесты предпочтительнее

Лучше тестировать:

  • реальный QueryClient;
  • настоящий cache;
  • invalidateQueries;
  • query lifecycle;
  • async updates.

Вместо:

mock(useQuery)

предпочтительно:

mock(fetcher)

Использование MSW вместо моков QueryClient

Популярный подход — Mock Service Worker.

MSW перехватывает реальные HTTP-запросы.

Пример handler:

http.get('/users', () => {
  return HttpResponse.json([
    { id: 1, name: 'Alex' },
  ])
})

Преимущества:

  • тестируется реальный networking;
  • работает QueryClient lifecycle;
  • поддерживаются retries;
  • поддерживается cancellation;
  • поведение ближе к production.

Комбинация MSW и тестового QueryClient

Наиболее стабильная архитектура тестирования:

render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
)

При этом:

  • HTTP мокируется через MSW;
  • QueryClient создаётся отдельно для каждого теста;
  • retries отключаются;
  • cache полностью изолирован.

Тестирование optimistic updates

Для optimistic updates важно проверять состояние кэша до и после rollback.

Пример:

queryClient.setQueryData(
  ['todos'],
  [{ id: 1, text: 'Old' }]
)

После optimistic update:

expect(
  queryClient.getQueryData(['todos'])
).toEqual([
  { id: 1, text: 'New' }
])

После rollback:

expect(
  queryClient.getQueryData(['todos'])
).toEqual([
  { id: 1, text: 'Old' }
])

Тестирование mutation cache

MutationCache также может проверяться напрямую.

Пример:

const mutations =
  queryClient.getMutationCache().getAll()

expect(mutations.length).toBe(1)

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

Для optimistic updates часто используется:

const cancelQueries = vi.spyOn(
  queryClient,
  'cancelQueries'
)

Проверка:

expect(cancelQueries)
  .toHaveBeenCalled()

Проверка query state

Можно получать полный state query.

Пример:

const state = queryClient
  .getQueryState(['users'])

Доступные поля:

state.status
state.fetchStatus
state.data
state.error
state.dataUpdatedAt

Проверка количества запросов

Иногда важно убедиться, что запрос не выполнялся повторно.

Пример:

expect(getUsers).toHaveBeenCalledTimes(1)

Это особенно важно при:

  • memoization;
  • staleTime;
  • background refetch;
  • focus refetch.

Мокирование infinite queries

Для infinite queries данные имеют особую структуру.

Пример:

queryClient.setQueryData(
  ['posts'],
  {
    pages: [
      [{ id: 1 }],
      [{ id: 2 }],
    ],

    pageParams: [1, 2],
  }
)

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

При SSR используется hydration.

Пример:

const dehydratedState = dehydrate(queryClient)

В тестах можно мокировать:

hydrate(queryClient, dehydratedState)

Изоляция тестов через helper-wrapper

Крупные проекты обычно создают единый wrapper.

export function createWrapper() {
  const queryClient =
    createTestQueryClient()

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

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

Для тестирования custom hooks:

const wrapper = createWrapper()

const { result } = renderHook(
  () => useUsers(),
  { wrapper }
)

Асинхронное ожидание query

Типичная ошибка:

expect(result.current.data)
  .toEqual(...)

Сразу после render данные ещё не загружены.

Правильно:

await waitFor(() => {
  expect(result.current.isSuccess)
    .toBe(true)
})

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

Для тестирования сброса состояния:

const resetQueries = vi.spyOn(
  queryClient,
  'resetQueries'
)

Ошибки при тестировании QueryClient

Наиболее распространённые проблемы:

Использование общего cache

Вызывает race conditions.

Включённые retries

Создают нестабильность.

Отсутствие cleanup

Приводит к утечкам состояния.

Настоящие сетевые запросы

Замедляют тесты.

stale queries

Провоцируют неожиданные refetch.

Использование fake timers без контроля

Ломает внутренние таймеры Query.


Fake Timers и QueryClient

TanStack Query активно использует таймеры.

При использовании:

vi.useFakeTimers()

необходимо учитывать:

  • retry delay;
  • garbage collection;
  • polling;
  • stale timers.

Иногда fake timers вызывают зависание query lifecycle.


Практический пример полноценного теста

import {
  QueryClientProvider,
} from '@tanstack/react-query'

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

test('renders users', async () => {
  const getUsers = vi.fn()

  getUsers.mockResolvedValue([
    { id: 1, name: 'Alex' },
  ])

  const queryClient =
    createTestQueryClient()

  render(
    <QueryClientProvider client={queryClient}>
      <Users />
    </QueryClientProvider>
  )

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

  expect(getUsers)
    .toHaveBeenCalledTimes(1)
})

Архитектура тестового окружения

Крупные проекты обычно разделяют:

test/
  helpers/
    query-client.js
    render.js
    msw.js

  mocks/
    handlers/

Подобная структура:

  • упрощает поддержку;
  • стандартизирует тесты;
  • предотвращает дублирование;
  • централизует QueryClient-конфигурацию.