При тестировании приложений с использованием TanStack Query основной
сложностью становится контроль состояния запросов, кэша, повторных
запросов, ошибок и асинхронного поведения. Реальный
QueryClient управляет внутренним кэшем, таймерами,
retry-механизмами, подписками и автоматическими обновлениями. В тестах
подобное поведение часто мешает изоляции сценариев.
Моки для QueryClient позволяют:
Без мокирования тесты начинают зависеть от времени выполнения, интервалов обновления и случайных состояний кэша.
QueryClient — центральный объект управления состоянием
запросов.
Пример стандартного создания:
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
Внутри объекта находятся:
Во время тестирования почти никогда не используется production-конфигурация клиента.
Распространённая ошибка:
const queryClient = new QueryClient()
test('test A', () => {
render(...)
})
test('test B', () => {
render(...)
})
Проблема заключается в том, что кэш между тестами сохраняется.
Последствия:
Правильный подход — создавать новый экземпляр для каждого теста.
Наиболее распространённая практика — создание helper-функции.
import { QueryClient } from '@tanstack/react-query'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
Почему отключают retry:
retry: false
По умолчанию TanStack Query повторяет запросы при ошибке.
Это создаёт проблемы:
Большинство 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,
},
},
})
}
Особенно важно при тестировании ошибок.
Наиболее распространённый вариант — мокирование самого fetcher.
Пример:
const getUsers = vi.fn()
getUsers.mockResolvedValue([
{ id: 1, name: 'Alex' },
])
Использование:
useQuery({
queryKey: ['users'],
queryFn: getUsers,
})
Преимущества:
Тестирование error-state критически важно.
Пример:
const getUsers = vi.fn()
getUsers.mockRejectedValue(
new Error('Server Error')
)
Проверка:
expect(screen.getByText(/server error/i))
.toBeInTheDocument()
Иногда требуется бесконечный pending-запрос.
Пример:
const getUsers = vi.fn(
() => new Promise(() => {})
)
Компонент остаётся в состоянии загрузки.
Один из самых мощных способов мокирования — прямое заполнение cache state.
queryClient.setQueryData(
['users'],
[
{ id: 1, name: 'Alex' },
]
)
Теперь компонент получает данные мгновенно без запроса.
QueryClient позволяет проверять итоговое состояние.
const users = queryClient.getQueryData(['users'])
expect(users).toEqual([
{ id: 1, name: 'Alex' },
])
Подобный подход полезен при тестировании:
Иногда необходимо проверить вызов invalidation.
Пример:
const invalidateQueries = vi.spyOn(
queryClient,
'invalidateQueries'
)
Проверка:
expect(invalidateQueries)
.toHaveBeenCalledWith({
queryKey: ['users'],
})
Аналогично:
const refetchQueries = vi.spyOn(
queryClient,
'refetchQueries'
)
Проверка:
expect(refetchQueries)
.toHaveBeenCalled()
При очистке состояния:
const removeQueries = vi.spyOn(
queryClient,
'removeQueries'
)
Особенно важно для logout-сценариев.
Даже при создании нового клиента желательно очищать cache.
afterEach(() => {
queryClient.clear()
})
Это удаляет:
Garbage collection может вызывать нестабильность.
Обычно в тестах устанавливают:
gcTime: Infinity
Пример:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: Infinity,
},
},
})
В браузерной среде тестов иногда происходят неожиданные refetch.
Причина:
refetchOnWindowFocus: true
В тестах обычно отключают:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
Аналогичная проблема:
refetchOnReconnect: false
Если приложение использует polling:
refetchInterval: 5000
Тесты начинают зависеть от таймеров.
Решение:
refetchInterval: false
Практический 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,
},
},
})
}
Если данные мгновенно становятся stale:
staleTime: 0
TanStack Query может инициировать повторные запросы.
Для стабильности тестов обычно используют:
staleTime: Infinity
Иногда мокируется сам 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,
}),
}
})
Подход используется редко.
Недостатки:
Подобный подход допустим:
Лучше тестировать:
Вместо:
mock(useQuery)
предпочтительно:
mock(fetcher)
Популярный подход — Mock Service Worker.
MSW перехватывает реальные HTTP-запросы.
Пример handler:
http.get('/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alex' },
])
})
Преимущества:
Наиболее стабильная архитектура тестирования:
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
При этом:
Для 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' }
])
MutationCache также может проверяться напрямую.
Пример:
const mutations =
queryClient.getMutationCache().getAll()
expect(mutations.length).toBe(1)
Для optimistic updates часто используется:
const cancelQueries = vi.spyOn(
queryClient,
'cancelQueries'
)
Проверка:
expect(cancelQueries)
.toHaveBeenCalled()
Можно получать полный state query.
Пример:
const state = queryClient
.getQueryState(['users'])
Доступные поля:
state.status
state.fetchStatus
state.data
state.error
state.dataUpdatedAt
Иногда важно убедиться, что запрос не выполнялся повторно.
Пример:
expect(getUsers).toHaveBeenCalledTimes(1)
Это особенно важно при:
Для infinite queries данные имеют особую структуру.
Пример:
queryClient.setQueryData(
['posts'],
{
pages: [
[{ id: 1 }],
[{ id: 2 }],
],
pageParams: [1, 2],
}
)
При SSR используется hydration.
Пример:
const dehydratedState = dehydrate(queryClient)
В тестах можно мокировать:
hydrate(queryClient, dehydratedState)
Крупные проекты обычно создают единый wrapper.
export function createWrapper() {
const queryClient =
createTestQueryClient()
return function Wrapper({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
Для тестирования custom hooks:
const wrapper = createWrapper()
const { result } = renderHook(
() => useUsers(),
{ wrapper }
)
Типичная ошибка:
expect(result.current.data)
.toEqual(...)
Сразу после render данные ещё не загружены.
Правильно:
await waitFor(() => {
expect(result.current.isSuccess)
.toBe(true)
})
Для тестирования сброса состояния:
const resetQueries = vi.spyOn(
queryClient,
'resetQueries'
)
Наиболее распространённые проблемы:
Вызывает race conditions.
Создают нестабильность.
Приводит к утечкам состояния.
Замедляют тесты.
Провоцируют неожиданные refetch.
Ломает внутренние таймеры Query.
TanStack Query активно использует таймеры.
При использовании:
vi.useFakeTimers()
необходимо учитывать:
Иногда 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/
Подобная структура: