Тестирование мутаций

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

Ключевая цель тестирования — гарантировать, что:

  • mutationFn вызывается с корректными параметрами
  • обработчики onSuccess, onError, onSettled выполняются в нужный момент
  • кэш обновляется или инвалидируется корректно
  • оптимистические обновления откатываются при ошибке
  • состояние мутации (isLoading, isError, isSuccess) соответствует фактическому сценарию

Базовая структура тестового окружения

Любое тестирование мутаций начинается с изоляции QueryClient. Общая ошибка — переиспользование одного экземпляра между тестами, что приводит к утечкам состояния кэша и флейки-тестам.

Типовой паттерн:

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

const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
      mutations: {
        retry: false,
      },
    },
  })

Каждый тест должен создавать новый экземпляр:

let queryClient

beforeEach(() => {
  queryClient = createTestQueryClient()
})

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

Важно отключать retry, иначе асинхронное поведение станет недетерминированным.


Рендеринг мутаций в тестах

При тестировании React-хуков используется @testing-library/react или @testing-library/react-hooks (или renderHook из RTL).

Обязательная обёртка:

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

const wrapper = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
)

Без QueryClientProvider мутации не смогут корректно работать с контекстом и кэшем.


Тестирование базовой мутации

Простейшая мутация — отправка данных на сервер.

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

const useCreatePost = () => {
  return useMutation({
    mutationFn: async (newPost) => {
      const res = await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      })
      return res.json()
    },
  })
}

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

  • вызов mutate
  • передачу параметров
  • результат выполнения

Пример:

import { renderHook, act } from '@testing-library/react'
import { useCreatePost } from './useCreatePost'

global.fetch = vi.fn()

test('создаёт пост через mutationFn', async () => {
  fetch.mockResolvedValue({
    json: async () => ({ id: 1, title: 'test' }),
  })

  const { result } = renderHook(() => useCreatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ title: 'test' })
  })

  expect(fetch).toHaveBeenCalledWith('/api/posts', expect.any(Object))
})

Тестирование состояния мутации

TanStack Query предоставляет реактивные состояния:

  • isPending
  • isSuccess
  • isError
  • data
  • error

Проверка успешного сценария

test('мутация переходит в success', async () => {
  fetch.mockResolvedValue({
    json: async () => ({ id: 1 }),
  })

  const { result } = renderHook(() => useCreatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ title: 'ok' })
  })

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

Тестирование ошибок мутации

Ошибка должна быть детерминированной, иначе тесты становятся нестабильными.

test('мутация обрабатывает ошибку', async () => {
  fetch.mockRejectedValue(new Error('Network error'))

  const { result } = renderHook(() => useCreatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ title: 'fail' })
  })

  expect(result.current.isError).toBe(true)
  expect(result.current.error).toBeDefined()
})

Тестирование onSuccess и побочных эффектов

onSuccess часто используется для инвалидирования кэша:

useMutation({
  mutationFn: createPost,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})

В тесте важно проверить не только факт вызова, но и влияние на кэш.

test('onSuccess инвалидирует query', async () => {
  const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')

  fetch.mockResolvedValue({
    json: async () => ({ id: 1 }),
  })

  const { result } = renderHook(() => useCreatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ title: 'x' })
  })

  expect(invalidateSpy).toHaveBeenCalledWith({
    queryKey: ['posts'],
  })
})

Тестирование оптимистических обновлений

Оптимистические мутации используют onMutate, onError и onSettled.

useMutation({
  mutationFn: updatePost,
  onMutate: async (newData) => {
    await queryClient.cancelQueries(['post', newData.id])

    const previous = queryClient.getQueryData(['post', newData.id])

    queryClient.setQueryData(['post', newData.id], newData)

    return { previous }
  },
  onError: (_err, newData, context) => {
    queryClient.setQueryData(['post', newData.id], context.previous)
  },
})

Тестирование optimistic update

test('optimistic update изменяет кэш', async () => {
  const { result } = renderHook(() => useUpdatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ id: 1, title: 'optimistic' })
  })

  const cached = queryClient.getQueryData(['post', 1])

  expect(cached.title).toBe('optimistic')
})

Тестирование rollback при ошибке

Ключевой сценарий — возврат предыдущего состояния.

test('rollback при ошибке мутации', async () => {
  queryClient.setQueryData(['post', 1], { id: 1, title: 'old' })

  fetch.mockRejectedValue(new Error('fail'))

  const { result } = renderHook(() => useUpdatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ id: 1, title: 'new' })
  })

  const cached = queryClient.getQueryData(['post', 1])

  expect(cached.title).toBe('old')
})

Тестирование invalidateQueries и refetch поведения

Мутации часто инициируют повторную загрузку данных.

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['posts'] })
}

Тестирование включает проверку:

  • вызова invalidateQueries
  • изменения состояния stale
  • повторного запроса (в интеграционных тестах)

Тестирование асинхронных гонок

Мутации могут пересекаться, особенно при быстрых кликах.

Проблема: порядок завершения mutationFn не гарантирован.

Решение — использование контролируемых промисов:

let resolveMutation

fetch.mockImplementation(
  () =>
    new Promise((resolve) => {
      resolveMutation = resolve
    })
)

Тест:

test('контроль завершения мутации', async () => {
  const { result } = renderHook(() => useCreatePost(), { wrapper })

  act(() => {
    result.current.mutate({ title: 'x' })
  })

  resolveMutation({
    json: async () => ({ id: 1 }),
  })

  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true)
  })
})

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

MSW (Mock Service Worker) используется для интеграционного уровня тестирования мутаций.

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

  • отсутствие моков fetch
  • реалистичное поведение API
  • проверка всей цепочки TanStack Query

Пример обработчика:

import { rest } from 'msw'

export const handlers = [
  rest.post('/api/posts', (req, res, ctx) => {
    return res(ctx.json({ id: 1 }))
  }),
]

Тест:

test('мутация через MSW', async () => {
  const { result } = renderHook(() => useCreatePost(), { wrapper })

  await act(async () => {
    result.current.mutate({ title: 'msw' })
  })

  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true)
  })
})

Инвалидация и согласованность кэша

Основная сложность тестирования мутаций — проверка консистентности кэша после цепочки действий.

Типичный сценарий:

  1. мутация обновляет данные
  2. инвалидирует список
  3. список перезапрашивается
  4. кэш обновляется новыми данными

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


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

При большом количестве тестов важно гарантировать полную очистку:

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

При необходимости:

queryClient.getQueryCache().clear()
queryClient.getMutationCache().clear()

Типичные ошибки тестирования мутаций

Основные проблемы, приводящие к нестабильным тестам:

  • отсутствие QueryClientProvider
  • общий QueryClient между тестами
  • включённый retry
  • отсутствие await на act
  • неконтролируемый fetch mock
  • отсутствие ожидания waitFor для async состояний

Стратегии уровней тестирования

Тестирование мутаций делится на уровни:

  • Unit: проверка mutationFn и callbacks
  • Hook-level: проверка состояния React Query
  • Integration: MSW + QueryClient
  • E2E: полная цепочка UI → API → cache

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