Тестирование мутаций в 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 предоставляет реактивные состояния:
isPendingisSuccessisErrordataerrortest('мутация переходит в 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 часто используется для инвалидирования
кэша:
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)
},
})
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')
})
Ключевой сценарий — возврат предыдущего состояния.
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')
})
Мутации часто инициируют повторную загрузку данных.
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
Тестирование включает проверку:
Мутации могут пересекаться, особенно при быстрых кликах.
Проблема: порядок завершения 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 (Mock Service Worker) используется для интеграционного уровня тестирования мутаций.
Преимущества:
Пример обработчика:
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)
})
})
Основная сложность тестирования мутаций — проверка консистентности кэша после цепочки действий.
Типичный сценарий:
Тесты должны учитывать, что промежуточные состояния возможны и не являются ошибкой.
При большом количестве тестов важно гарантировать полную очистку:
afterEach(() => {
queryClient.clear()
vi.clearAllMocks()
})
При необходимости:
queryClient.getQueryCache().clear()
queryClient.getMutationCache().clear()
Основные проблемы, приводящие к нестабильным тестам:
QueryClientProvideractwaitFor для async состоянийТестирование мутаций делится на уровни:
Каждый уровень закрывает отдельный класс рисков, и их смешивание приводит к дублированию и нестабильности.