Интеграционные тесты в контексте TanStack Query проверяют взаимодействие нескольких уровней системы: компоненты React, хуки запросов, QueryClient, кэш, сетевой слой и механизмы синхронизации состояния. Основная цель таких тестов — подтвердить корректную работу цепочки «UI → query/mutation → серверный слой (mock) → кэш → обновление UI».
В отличие от юнит-тестов, где изолируется отдельная функция или хук, интеграционные тесты фокусируются на поведении системы как единого целого.
Корректная работа TanStack Query в тестах требует создания
изолированного QueryClient для каждого теста. Это
предотвращает утечку состояния кэша между тестами.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
Ключевые параметры:
retry: false — отключает повторные запросы, упрощая
предсказуемость тестовgcTime: 0 — ускоряет очистку кэшаДля корректного тестирования компонентов с query-хуками требуется общий wrapper:
import React from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { createTestQueryClient } from './createTestQueryClient'
export const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
Важно: каждый тест должен получать новый QueryClient,
иначе кэширование начнёт влиять на результат.
Интеграционные тесты чаще всего строятся вокруг
renderHook из React Testing Library.
import { renderHook, waitFor } from '@testing-library/react'
import { useQuery } from '@tanstack/react-query'
import { createWrapper } from './createWrapper'
Пример теста:
const fetchUser = async () => {
const res = await fetch('/api/user')
return res.json()
}
test('загружает данные пользователя', async () => {
const { result } = renderHook(
() => useQuery({ queryKey: ['user'], queryFn: fetchUser }),
{ wrapper: createWrapper() }
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toEqual({
id: 1,
name: 'John',
})
})
Интеграционные тесты TanStack Query почти всегда требуют мокирования HTTP-слоя. Используются два подхода:
jest.mock(fetch)import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(
ctx.json({
id: 1,
name: 'John',
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
MSW предпочтительнее, так как тестирует поведение на уровне сети, а не реализации fetch.
TanStack Query предоставляет детализированные статусы запроса:
isLoadingisFetchingisErrorisSuccesstest('отображает состояние загрузки', async () => {
const { result } = renderHook(
() => useQuery({ queryKey: ['user'], queryFn: fetchUser }),
{ wrapper: createWrapper() }
)
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
Одной из ключевых особенностей TanStack Query является кэш. Интеграционные тесты часто проверяют повторное использование данных.
test('использует кэш при повторном запросе', async () => {
let callCount = 0
const fetchUser = async () => {
callCount++
return { id: 1 }
}
const { result, rerender } = renderHook(
() => useQuery({ queryKey: ['user'], queryFn: fetchUser }),
{ wrapper: createWrapper() }
)
await waitFor(() => result.current.isSuccess)
rerender()
await waitFor(() => result.current.isSuccess)
expect(callCount).toBe(1)
})
Интеграционные тесты часто проверяют взаимодействие
invalidateQueries и повторного запроса данных.
import { useQueryClient } from '@tanstack/react-query'
test('инвалидация вызывает повторный запрос', async () => {
let callCount = 0
const fetchUser = async () => {
callCount++
return { id: 1 }
}
const { result } = renderHook(
() => {
const queryClient = useQueryClient()
return {
query: useQuery({ queryKey: ['user'], queryFn: fetchUser }),
invalidate: () => queryClient.invalidateQueries({ queryKey: ['user'] }),
}
},
{ wrapper: createWrapper() }
)
await waitFor(() => result.current.query.isSuccess)
expect(callCount).toBe(1)
result.current.invalidate()
await waitFor(() => {
expect(callCount).toBe(2)
})
})
Mutations в TanStack Query требуют проверки не только выполнения запроса, но и изменения кэша.
import { useMutation, useQueryClient } from '@tanstack/react-query'
const updateUser = async (data) => {
return fetch('/api/user', {
method: 'POST',
body: JSON.stringify(data),
}).then(res => res.json())
}
test('mutation обновляет данные и инвалидирует query', async () => {
const { result } = renderHook(
() => {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] })
},
})
return mutation
},
{ wrapper: createWrapper() }
)
result.current.mutate({ name: 'New Name' })
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
Оптимистические обновления требуют проверки промежуточного состояния кэша до завершения запроса.
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['user'] })
const previous = queryClient.getQueryData(['user'])
queryClient.setQueryData(['user'], newUser)
return { previous }
},
onError: (_err, _newUser, context) => {
queryClient.setQueryData(['user'], context.previous)
},
})
Тестирование:
test('оптимистическое обновление кэша', async () => {
const { result } = renderHook(
() => {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
const prev = queryClient.getQueryData(['user'])
queryClient.setQueryData(['user'], newUser)
return { prev }
},
onError: (_err, _vars, ctx) => {
queryClient.setQueryData(['user'], ctx.prev)
},
})
return { mutation, queryClient }
},
{ wrapper: createWrapper() }
)
result.current.mutation.mutate({ id: 1, name: 'Optimistic' })
expect(result.current.queryClient.getQueryData(['user'])).toEqual({
id: 1,
name: 'Optimistic',
})
})
TanStack Query работает через асинхронные состояния, поэтому ключевым
инструментом становится waitFor.
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
Дополнительно могут использоваться:
waitForNextUpdate (в older setups)Обязательный аспект интеграционных тестов — очистка состояния:
afterEach(() => {
queryClient.clear()
})
При использовании глобального клиента это критично, иначе кэш будет влиять на результаты следующих тестов.
const fetchWithError = async () => {
throw new Error('Network error')
}
test('обрабатывает ошибку запроса', async () => {
const { result } = renderHook(
() => useQuery({ queryKey: ['user'], queryFn: fetchWithError }),
{ wrapper: createWrapper() }
)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBeDefined()
})
Интеграционные тесты часто включают несколько связанных query:
useQuery({ queryKey: ['user'], queryFn: fetchUser })
useQuery({ queryKey: ['posts', userId], queryFn: fetchPosts })
Проверяется:
test('зависимый запрос posts', async () => {
const { result } = renderHook(
() => {
const user = useQuery({ queryKey: ['user'], queryFn: fetchUser })
return useQuery({
queryKey: ['posts', user.data?.id],
queryFn: fetchPosts,
enabled: !!user.data?.id,
})
},
{ wrapper: createWrapper() }
)
await waitFor(() => result.current.isSuccess)
expect(result.current.data).toBeDefined()
})
Контроль времени позволяет тестировать стратегию актуальности данных.
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 1000,
})
Тесты проверяют:
TanStack Query умеет дедуплицировать одинаковые запросы:
test('дедупликация параллельных запросов', async () => {
let count = 0
const fetchUser = async () => {
count++
return { id: 1 }
}
const wrapper = createWrapper()
const hook1 = renderHook(() => useQuery({ queryKey: ['user'], queryFn: fetchUser }), { wrapper })
const hook2 = renderHook(() => useQuery({ queryKey: ['user'], queryFn: fetchUser }), { wrapper })
await waitFor(() => hook1.result.current.isSuccess)
await waitFor(() => hook2.result.current.isSuccess)
expect(count).toBe(1)
})