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

Интеграционные тесты в контексте 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 — ускоряет очистку кэша
  • отсутствие staleTime по умолчанию позволяет явно контролировать поведение данных

Обёртка провайдера для тестов

Для корректного тестирования компонентов с 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, иначе кэширование начнёт влиять на результат.


Тестирование useQuery через renderHook

Интеграционные тесты чаще всего строятся вокруг 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)
  • MSW (Mock Service Worker)

Пример с MSW

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 предоставляет детализированные статусы запроса:

  • isLoading
  • isFetching
  • isError
  • isSuccess
test('отображает состояние загрузки', 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)
  • fake timers (jest.useFakeTimers) для контроля retry и staleTime
  • flushPromises для ускоренного разрешения промисов

Изоляция QueryClient между тестами

Обязательный аспект интеграционных тестов — очистка состояния:

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

Проверяется:

  • последовательность загрузки
  • зависимость queryKey
  • повторные запросы при изменении зависимостей
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()
})

Поведение staleTime и refetch

Контроль времени позволяет тестировать стратегию актуальности данных.

useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 1000,
})

Тесты проверяют:

  • не происходит ли повторный запрос до истечения staleTime
  • происходит ли refetch после перехода в stale состояние

Проверка параллельных запросов

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