Тестирование приложений, использующих TanStack Query, требует отдельного подхода из-за внутреннего кеширования, асинхронных обновлений, повторных запросов, механизмов инвалидирования и автоматической синхронизации состояния. Обычный рендер React-компонента без подготовки окружения приводит к нестабильным тестам, утечкам состояния между кейсами и трудноуловимым ошибкам.
Корректно настроенное тестовое окружение решает несколько задач:
Для тестирования TanStack Query чаще всего используются:
Пример установки для Vitest:
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw
Для Jest:
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw
Если приложение использует TypeScript:
npm install -D @types/jest
Главная ошибка при тестировании TanStack Query — использование одного глобального QueryClient для всех тестов.
Плохой пример:
const queryClient = new QueryClient()
Такой клиент сохраняет состояние между тестами:
Это приводит к ситуации, когда один тест влияет на другой.
Правильный подход — создание нового QueryClient для каждого теста.
Наиболее распространённый способ — вынести создание клиента в отдельную функцию.
import { QueryClient } from '@tanstack/react-query'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
}
По умолчанию TanStack Query автоматически повторяет failed-запросы.
Стандартное значение:
retry: 3
Это создаёт несколько проблем:
Поэтому retry практически всегда отключают:
retry: false
Компоненты TanStack Query требуют React-контекста.
Без QueryClientProvider появится ошибка:
No QueryClient set, use QueryClientProvider to set one
Поэтому создаётся специальная тестовая обёртка.
import { QueryClientProvider } from '@tanstack/react-query'
import { createTestQueryClient } from './createTestQueryClient'
export function createWrapper() {
const queryClient = createTestQueryClient()
return function Wrapper({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
import { render } from '@testing-library/react'
import { createWrapper } from './test-utils'
test('renders users', async () => {
render(<Users />, {
wrapper: createWrapper(),
})
})
В тестах часто отключают автоматическую очистку кеша.
В новых версиях TanStack Query используется gcTime вместо cacheTime.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: Infinity,
},
},
})
Это особенно важно для:
Во время тестов TanStack Query выводит ошибки в console.error.
Это засоряет вывод:
Error: Request failed
Даже если ошибка ожидаемая.
Для подавления логов создают кастомный logger.
Для TanStack Query v4:
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
logger: {
log: console.log,
warn: console.warn,
error: () => {},
},
})
В более новых версиях рекомендуется подавлять ошибки через mock console.error.
Пример для Vitest:
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
Для Jest:
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
jest.restoreAllMocks()
})
React Testing Library идеально подходит для тестирования TanStack Query, поскольку библиотека ориентирована на пользовательское поведение, а не на внутреннюю реализацию.
Типичная структура теста:
import { render, screen } from '@testing-library/react'
test('shows loading state', () => {
render(<Users />, {
wrapper: createWrapper(),
})
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
TanStack Query работает асинхронно.
Поэтому необходимы:
Пример:
test('renders fetched data', async () => {
render(<Users />, {
wrapper: createWrapper(),
})
expect(screen.getByText(/loading/i)).toBeInTheDocument()
expect(await screen.findByText('John')).toBeInTheDocument()
})
waitFor используется, когда требуется дождаться изменения состояния.
import { waitFor } from '@testing-library/react'
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument()
})
Некоторые разработчики пытаются искусственно ожидать завершение запросов:
await new Promise(resolve => setTimeout(resolve, 1000))
Это плохая практика:
TanStack Query должен тестироваться через ожидание UI-состояния.
MSW — основной инструмент мокирования API для TanStack Query.
Преимущества MSW:
npm install msw --save-dev
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{
id: 1,
name: 'John',
},
])
}),
]
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Обычно создаётся единый setup-файл.
Пример:
import '@testing-library/jest-dom'
import { beforeAll, afterAll, afterEach, vi } from 'vitest'
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => {
server.resetHandlers()
vi.restoreAllMocks()
})
afterAll(() => server.close())
Пример vitest.config.js:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.js'],
},
})
Пример jest.config.js:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
}
Компонент:
function Users() {
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
if (isLoading) {
return <div>Loading...</div>
}
return (
<div>
{data.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
)
}
Тест:
test('shows loading state', () => {
render(<Users />, {
wrapper: createWrapper(),
})
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
test('renders fetched users', async () => {
render(<Users />, {
wrapper: createWrapper(),
})
expect(await screen.findByText('John')).toBeInTheDocument()
})
MSW позволяет переопределять handlers прямо внутри теста.
import { http, HttpResponse } from 'msw'
test('shows error state', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, {
status: 500,
})
}),
)
render(<Users />, {
wrapper: createWrapper(),
})
expect(await screen.findByText(/error/i)).toBeInTheDocument()
})
Мутации требуют отдельной проверки:
function AddUser() {
const mutation = useMutation({
mutationFn: createUser,
})
return (
<button onCl ick={() => mutation.mutate()}>
Add user
</button>
)
}
import userEvent from '@testing-library/user-event'
test('creates user', async () => {
render(<AddUser />, {
wrapper: createWrapper(),
})
await userEvent.click(
screen.getByRole('button', {
name: /add user/i,
}),
)
await waitFor(() => {
expect(createUser).toHaveBeenCalled()
})
})
Optimistic updates особенно чувствительны к качеству тестового окружения.
Важно проверять:
expect(screen.getByText('New User')).toBeInTheDocument()
до завершения запроса.
server.use(
http.post('/api/users', () => {
return new HttpResponse(null, {
status: 500,
})
}),
)
После ошибки UI должен вернуться к исходному состоянию.
Даже при создании нового QueryClient иногда выполняют дополнительную очистку.
afterEach(() => {
queryClient.clear()
})
Особенно это полезно в:
Для тестирования кастомных hooks применяется renderHook.
import { renderHook } from '@testing-library/react'
test('fetches users', async () => {
const { result } = renderHook(() =>
useUsers(),
{
wrapper: createWrapper(),
},
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toHaveLength(1)
})
TanStack Query предоставляет множество флагов состояния:
isLoading
isFetching
isError
isSuccess
isPending
isRefetching
Тесты должны проверять именно пользовательское поведение, а не только внутренние флаги.
Плохой тест:
expect(result.current.isSuccess).toBe(true)
Лучший вариант:
expect(screen.getByText('John')).toBeInTheDocument()
Иногда необходимо тестировать:
В этом случае используются fake timers.
Vitest:
vi.useFakeTimers()
Jest:
jest.useFakeTimers()
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
refetchInterval: 5000,
})
Тест:
vi.advanceTimersByTime(5000)
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 10000,
})
Проверка повторного refetch:
vi.advanceTimersByTime(10001)
В крупных проектах создают единый test-utils модуль.
Пример структуры:
src/
├─ test/
│ ├─ createTestQueryClient.js
│ ├─ createWrapper.js
│ ├─ setupTests.js
│ ├─ mocks/
│ │ ├─ handlers.js
│ │ └─ server.js
import { render } from '@testing-library/react'
import { QueryClientProvider } from '@tanstack/react-query'
import { createTestQueryClient } from './createTestQueryClient'
export function renderWithClient(ui) {
const testQueryClient = createTestQueryClient()
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
),
})
}
renderWithClient(<Users />)
Инвалидирование — один из ключевых механизмов TanStack Query.
Пример:
queryClient.invalidateQueries({
queryKey: ['users'],
})
Во время тестов важно проверять:
const data = queryClient.getQueryData(['users'])
const mutations = queryClient
.getMutationCache()
.getAll()
Integration tests для TanStack Query обычно проверяют:
MSW делает такие тесты максимально близкими к реальному приложению.
Иногда тестируются только hooks без UI.
Пример:
const { result } = renderHook(() =>
useUser(1),
{
wrapper: createWrapper(),
},
)
Однако чрезмерное тестирование внутренних hook-состояний делает тесты хрупкими.
Основные причины нестабильности:
Каждый тест должен иметь:
Необходимо контролировать:
MSW предпочтительнее:
global.fetch = vi.fn()
поскольку тестирует реальный сетевой слой.
Лучше:
expect(screen.getByText('John'))
хуже:
expect(result.current.data[0].name)
Общие helper-функции:
import { QueryClient } from '@tanstack/react-query'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
})
}
import { render } from '@testing-library/react'
import { QueryClientProvider } from '@tanstack/react-query'
import { createTestQueryClient } from './createTestQueryClient'
export function renderWithClient(ui) {
const queryClient = createTestQueryClient()
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})
}
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{
id: 1,
name: 'John',
},
])
}),
]
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
import '@testing-library/jest-dom'
import { beforeAll, afterAll, afterEach } from 'vitest'
import { server } from './mocks/server'
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
import { screen } from '@testing-library/react'
import { renderWithClient } from './renderWithClient'
import { Users } from './Users'
test('renders users', async () => {
renderWithClient(<Users />)
expect(
await screen.findByText('John'),
).toBeInTheDocument()
})