Настройка тестового окружения

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

Корректно настроенное тестовое окружение решает несколько задач:

  • изолирует кеш между тестами;
  • отключает автоматические retry-механизмы;
  • убирает сетевые зависимости;
  • обеспечивает предсказуемое асинхронное поведение;
  • позволяет тестировать loading/error/success-состояния;
  • упрощает проверку optimistic updates;
  • делает тесты детерминированными.

Установка зависимостей

Для тестирования TanStack Query чаще всего используются:

  • Jest или Vitest;
  • React Testing Library;
  • MSW;
  • jsdom;
  • user-event;
  • mock-функции.

Пример установки для 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

Изоляция QueryClient

Главная ошибка при тестировании TanStack Query — использование одного глобального QueryClient для всех тестов.

Плохой пример:

const queryClient = new QueryClient()

Такой клиент сохраняет состояние между тестами:

  • кеш запросов;
  • mutation cache;
  • retry state;
  • timestamps;
  • observers.

Это приводит к ситуации, когда один тест влияет на другой.

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


Создание фабрики QueryClient

Наиболее распространённый способ — вынести создание клиента в отдельную функцию.

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

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

Почему retry отключают в тестах

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

Стандартное значение:

retry: 3

Это создаёт несколько проблем:

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

Поэтому retry практически всегда отключают:

retry: false

Настройка QueryClientProvider

Компоненты TanStack Query требуют React-контекста.

Без QueryClientProvider появится ошибка:

No QueryClient set, use QueryClientProvider to set one

Поэтому создаётся специальная тестовая обёртка.


Создание test wrapper

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>
    )
  }
}

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

import { render } from '@testing-library/react'
import { createWrapper } from './test-utils'

test('renders users', async () => {
  render(<Users />, {
    wrapper: createWrapper(),
  })
})

Настройка gcTime

В тестах часто отключают автоматическую очистку кеша.

В новых версиях TanStack Query используется gcTime вместо cacheTime.

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

Это особенно важно для:

  • snapshot testing;
  • сложных async-тестов;
  • optimistic updates;
  • долгих integration-тестов.

Отключение логирования ошибок

Во время тестов TanStack Query выводит ошибки в console.error.

Это засоряет вывод:

Error: Request failed

Даже если ошибка ожидаемая.

Для подавления логов создают кастомный logger.


Настройка 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

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()
})

Тестирование async-состояний

TanStack Query работает асинхронно.

Поэтому необходимы:

  • findBy;
  • waitFor;
  • async/await.

Пример:

test('renders fetched data', async () => {
  render(<Users />, {
    wrapper: createWrapper(),
  })

  expect(screen.getByText(/loading/i)).toBeInTheDocument()

  expect(await screen.findByText('John')).toBeInTheDocument()
})

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

waitFor используется, когда требуется дождаться изменения состояния.

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

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

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

Некоторые разработчики пытаются искусственно ожидать завершение запросов:

await new Promise(resolve => setTimeout(resolve, 1000))

Это плохая практика:

  • тесты становятся медленными;
  • появляются race conditions;
  • тесты нестабильны;
  • возникает зависимость от времени.

TanStack Query должен тестироваться через ожидание UI-состояния.


Mock Service Worker (MSW)

MSW — основной инструмент мокирования API для TanStack Query.

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

  • работает на уровне HTTP;
  • не требует мокирования fetch;
  • позволяет тестировать реальные запросы;
  • подходит для browser/node;
  • поддерживает REST и GraphQL.

Установка MSW

npm install msw --save-dev

Создание handlers

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      {
        id: 1,
        name: 'John',
      },
    ])
  }),
]

Настройка MSW server

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Инициализация MSW

beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

Структура setupTests

Обычно создаётся единый 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

Пример vitest.config.js:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/setupTests.js'],
  },
})

Настройка Jest

Пример jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
}

Тестирование loading-state

Компонент:

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()
})

Тестирование success-state

test('renders fetched users', async () => {
  render(<Users />, {
    wrapper: createWrapper(),
  })

  expect(await screen.findByText('John')).toBeInTheDocument()
})

Тестирование error-state

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()
})

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

Мутации требуют отдельной проверки:

  • pending-state;
  • optimistic update;
  • rollback;
  • invalidation;
  • success callback.

Пример mutation-компонента

function AddUser() {
  const mutation = useMutation({
    mutationFn: createUser,
  })

  return (
    <button onCl ick={() => mutation.mutate()}>
      Add user
    </button>
  )
}

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

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

Optimistic updates особенно чувствительны к качеству тестового окружения.

Важно проверять:

  • обновление UI до ответа сервера;
  • rollback при ошибке;
  • синхронизацию кеша;
  • invalidation.

Проверка optimistic UI

expect(screen.getByText('New User')).toBeInTheDocument()

до завершения запроса.


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

server.use(
  http.post('/api/users', () => {
    return new HttpResponse(null, {
      status: 500,
    })
  }),
)

После ошибки UI должен вернуться к исходному состоянию.


Очистка кеша между тестами

Даже при создании нового QueryClient иногда выполняют дополнительную очистку.

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

Особенно это полезно в:

  • integration tests;
  • shared helpers;
  • custom render utilities.

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

Для тестирования кастомных 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)
})

Проверка состояния query

TanStack Query предоставляет множество флагов состояния:

isLoading
isFetching
isError
isSuccess
isPending
isRefetching

Тесты должны проверять именно пользовательское поведение, а не только внутренние флаги.

Плохой тест:

expect(result.current.isSuccess).toBe(true)

Лучший вариант:

expect(screen.getByText('John')).toBeInTheDocument()

Fake timers

Иногда необходимо тестировать:

  • staleTime;
  • refetchInterval;
  • retryDelay;
  • debounce;
  • polling.

В этом случае используются fake timers.

Vitest:

vi.useFakeTimers()

Jest:

jest.useFakeTimers()

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

const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  refetchInterval: 5000,
})

Тест:

vi.advanceTimersByTime(5000)

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

const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 10000,
})

Проверка повторного refetch:

vi.advanceTimersByTime(10001)

Подготовка reusable test utilities

В крупных проектах создают единый test-utils модуль.

Пример структуры:

src/
├─ test/
│  ├─ createTestQueryClient.js
│  ├─ createWrapper.js
│  ├─ setupTests.js
│  ├─ mocks/
│  │  ├─ handlers.js
│  │  └─ server.js

Универсальный render helper

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>
    ),
  })
}

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

renderWithClient(<Users />)

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

Инвалидирование — один из ключевых механизмов TanStack Query.

Пример:

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

Во время тестов важно проверять:

  • произошёл ли refetch;
  • обновился ли UI;
  • изменился ли кеш.

Проверка query cache

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

Проверка mutation cache

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

Интеграционные тесты

Integration tests для TanStack Query обычно проверяют:

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

MSW делает такие тесты максимально близкими к реальному приложению.


Unit-тесты hooks

Иногда тестируются только hooks без UI.

Пример:

const { result } = renderHook(() =>
  useUser(1),
  {
    wrapper: createWrapper(),
  },
)

Однако чрезмерное тестирование внутренних hook-состояний делает тесты хрупкими.


Проблемы асинхронных тестов

Основные причины нестабильности:

  • shared QueryClient;
  • включённый retry;
  • setTimeout;
  • отсутствие await;
  • race conditions;
  • fake timers без cleanup;
  • неочищенный MSW state;
  • глобальные mocks.

Рекомендации по стабильному тестовому окружению

Изоляция состояния

Каждый тест должен иметь:

  • собственный QueryClient;
  • независимый кеш;
  • отдельные mocks.

Предсказуемость времени

Необходимо контролировать:

  • retry;
  • polling;
  • staleTime;
  • debounce.

Минимизация моков

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

global.fetch = vi.fn()

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


Проверка поведения, а не реализации

Лучше:

expect(screen.getByText('John'))

хуже:

expect(result.current.data[0].name)

Централизация тестовой инфраструктуры

Общие helper-функции:

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

Полный пример тестового окружения

createTestQueryClient.js

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

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

renderWithClient.js

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>
    ),
  })
}

handlers.js

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      {
        id: 1,
        name: 'John',
      },
    ])
  }),
]

server.js

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

setupTests.js

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()
})

users.test.js

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()
})