Summarize with AI:
Here are the quirks to note about testing in TanStack Query that may differ from other Vue component testing and how to get set up so it feels like regular testing again.
A while back, I wrote about How to Manage Composition API Refs in Vue 3 When Unit Testing, and more recently we’ve been working through the TanStack Query series, finishing up with mutations and pagination.
Now we get to the part nobody really wants to write but that pays for itself the first time a refactor doesn’t break in production: tests. Testing components that use TanStack Query has a few specific gotchas that don’t really come up with regular Vue components, and I want to walk through how I usually approach it.
A normal component test is straightforward: mount the component, assert what’s in the DOM, maybe simulate some clicks, assert again. Done.
A TanStack Query component adds a few wrinkles:
QueryClient provided through the app instance. Mount it without one and you’ll get a vague error.fetch, axios, whatever) needs to be mocked, or your tests will hit your real API.Each of these is solvable in a couple of lines. The trick is knowing where each line goes.
The first thing we need is a way to mount a component with a fresh QueryClient in each test. I usually wrap this in a small helper.
tests/utils/withQueryClient.js
import { mount } from '@vue/test-utils'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
export const mountWithQuery = (component, options = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
return mount(component, {
...options,
global: {
...(options.global ?? {}),
plugins: [
...(options.global?.plugins ?? []),
[VueQueryPlugin, { queryClient }],
],
},
})
}
A few things worth pointing out:
QueryClient for each test. This is critical. If you reuse a single client across tests, the cache from one test bleeds into the next, and you’ll spend an afternoon hunting down phantom failures.retry: false disables the default retry behavior. In a test, we want failures to be failures, not “wait three seconds for two more retries before giving up.”gcTime: 0 makes sure cached data isn’t kept around. Combined with the fresh client, this gives us proper isolation.In real codebases, I usually expose this as a helper from a tests/utils/ folder and have my test files import it. Whether you use Vitest or Jest, the shape is the same.
Now for the API. Our component reaches the outside world through fetch, and in a test we absolutely do not want it talking to a real server. So we swap fetch for a stub that returns whatever we tell it to.
Vitest makes this painless with vi.stubGlobal. I set it up once in a global setup file so every test starts with a fresh, fake fetch and the real one gets restored when the test is done.
tests/setup.js
import { afterEach, beforeEach, vi } from 'vitest'
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
Point Vitest at this file with setupFiles: ['./tests/setup.js'] in your vitest.config.js, and every test gets a clean fetch mock for free.
There’s one slightly annoying detail. fetch doesn’t resolve to your data directly, it resolves to a Response object with .ok, .status, and a .json() method. Since we’ll be building those over and over, a tiny helper keeps the tests readable.
tests/utils/jsonResponse.js
export const jsonResponse = (data, { ok = true, status = 200 } = {}) => ({
ok,
status,
json: async () => data,
})
That’s the entire mocking setup. No extra dependencies, nothing intercepting at the network level, just a fake function that hands back the same shape fetch would.
Let’s put it all together. Imagine we have the following component:
UserList.vue
<script setup>
import { useQuery } from '@tanstack/vue-query'
const fetchUsers = async () => {
const response = await fetch('https://myapp.com/users')
if (!response.ok) {
throw new Error('Failed to fetch users')
}
return response.json()
}
const { data, isPending, isError } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
</script>
<template>
<p v-if="isPending">Loading...</p>
<p v-else-if="isError">Error</p>
<ul v-else>
<li v-for="user in data" :key="user.id">{{ user.name }}</li>
</ul>
</template>
And here’s the test.
UserList.spec.js
import { describe, it, expect } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { mountWithQuery } from './utils/withQueryClient'
import { jsonResponse } from './utils/jsonResponse'
import UserList from '../src/components/UserList.vue'
describe('UserList', () => {
it('shows a loading state and then the user list', async () => {
fetch.mockResolvedValue(
jsonResponse([
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Grace Hopper' },
])
)
const wrapper = mountWithQuery(UserList)
expect(wrapper.text()).toContain('Loading...')
await flushPromises()
expect(wrapper.text()).toContain('Ada Lovelace')
expect(wrapper.text()).toContain('Grace Hopper')
})
})
Let’s break it down. First we tell our fake fetch what to resolve to (because the setup file stubbed it globally, we can reach for fetch directly). Then we mount the component, immediately assert that the loading state is visible (the query hasn’t resolved yet), flushPromises to let the microtasks run, and finally assert that the list rendered.
flushPromises from @vue/test-utils is the easiest way I’ve found to get past the async boundary. It awaits every queued promise, including the mocked fetch and the reactive update inside TanStack Query. If you forget it, you’ll see the loading state forever and a confused test runner.
If flushPromises isn’t enough (some flows need a few extra ticks), you can also lean on vi.waitFor or the equivalent in your test runner, which retries an assertion until it passes or times out.
Errors are easy to test once the happy path works. We just tell fetch to resolve to a failed response for that one test:
it('shows an error state when the API fails', async () => {
fetch.mockResolvedValue(
jsonResponse({ message: 'oops' }, { ok: false, status: 500 })
)
const wrapper = mountWithQuery(UserList)
await flushPromises()
expect(wrapper.text()).toContain('Error')
})
Notice how our component checks response.ok and throws when it’s false. That thrown error is what flows through TanStack Query into the isError branch, which is exactly what we’re asserting on. This is also why retry: false in our helper matters so much: without it, the query would dutifully retry the failing request a few times before giving up, and your test would crawl.
Because the setup file gives every test a brand-new fetch mock, this mockResolvedValue only affects this one test. Nothing to clean up by hand.
Mutations are very similar, but with a twist. Because they don’t fire automatically on mount, we have to actually trigger them. Let’s say we have a component that creates a user when a button is clicked.
CreateUser.spec.js
import { describe, it, expect } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { mountWithQuery } from './utils/withQueryClient'
import { jsonResponse } from './utils/jsonResponse'
import CreateUser from '../src/components/CreateUser.vue'
it('creates a user when the form is submitted', async () => {
fetch.mockResolvedValue(
jsonResponse(
{ id: 1, name: 'Ada', email: 'ada@example.com' },
{ status: 201 }
)
)
const wrapper = mountWithQuery(CreateUser)
await wrapper.find('input[name="name"]').setValue('Ada')
await wrapper.find('input[name="email"]').setValue('ada@example.com')
await wrapper.find('form').trigger('submit')
await flushPromises()
const [url, options] = fetch.mock.calls[0]
expect(url).toBe('https://myapp.com/users')
expect(JSON.parse(options.body)).toEqual({
name: 'Ada',
email: 'ada@example.com',
})
expect(wrapper.text()).toContain('User created')
})
Two things to call out. First, because fetch is a vi.fn(), every call is recorded. fetch.mock.calls[0] gives us the arguments of the first call, so we can pull out the URL and the request options and assert against them. We parse options.body back from JSON to confirm the right payload went out the door, not just that something happened. That’s the part of a mutation that actually tends to break.
Second, we’re using two awaits back to back: one for the form submission, and flushPromises to let the mutation’s async work resolve. You’ll see this pattern a lot. When in doubt, throw in another await flushPromises() and see if your assertion passes.
One of the most valuable tests you can write covers the interaction between a mutation and the queries it invalidates. When you create a user, does the list actually refresh? This is the integration that breaks most often, and it’s easy to get subtly wrong: forget invalidateQueries in your mutation or invalidate the wrong key, and the list just sits there showing stale data.
To test it, we mount a page that brings both pieces together. Picture a UserPage that renders the user list and a create form (the form is probably its own child component, but from the test’s point of view we just mount the page and treat it as one unit). We create a user, then assert the new name appears in the list.
This is actually where mocking fetch directly reads quite nicely. The component makes three requests in a known order, so we queue up three responses with mockResolvedValueOnce.
UserPage.spec.js
import { describe, it, expect } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { mountWithQuery } from './utils/withQueryClient'
import { jsonResponse } from './utils/jsonResponse'
import UserPage from '../src/pages/UserPage.vue'
it('refreshes the user list after creating a user', async () => {
fetch
// initial list load
.mockResolvedValueOnce(jsonResponse([{ id: 1, name: 'Ada' }]))
// the create mutation
.mockResolvedValueOnce(
jsonResponse({ id: 2, name: 'Grace' }, { status: 201 })
)
// the refetch triggered by invalidateQueries
.mockResolvedValueOnce(
jsonResponse([
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
])
)
const wrapper = mountWithQuery(UserPage)
await flushPromises()
expect(wrapper.text()).toContain('Ada')
expect(wrapper.text()).not.toContain('Grace')
await wrapper.find('input[name="name"]').setValue('Grace')
await wrapper.find('form').trigger('submit')
await flushPromises()
await flushPromises()
expect(wrapper.text()).toContain('Grace')
})
Let’s break it down. The three mockResolvedValueOnce calls line up with the exact sequence of requests the page makes: the list loads on mount, the form submission POSTs the new user and then invalidateQueries fires off a fresh GET for the list.
Notice the two flushPromises after the submit. This is the longest async cascade in the article (submit, the mutation resolves, its onSuccess calls invalidateQueries, the list refetches, then the DOM updates), and that’s more hops than a single flush reliably drains.
When a test like this comes out flaky, an extra await flushPromises() is almost always the fix. If you’d rather not count ticks by hand, wrap the final assertion in vi.waitFor(() => expect(wrapper.text()).toContain('Grace')), which retries until it passes or times out.
This test mirrors what an actual user does: see the list, submit a form, see the updated list. It catches a whole class of bug that narrow unit tests don’t, like forgetting invalidateQueries in your mutation or invalidating the wrong key.
I’ve found that having one or two of these per major flow (create, update, delete) catches more real regressions than a hundred isolated component tests.
mockResolvedValue for the common case, a one-off mockResolvedValueOnce or mockRejectedValue for the test that needs something different. Keeps each test focused on what’s special about it.staleTime or refetching, pause. That’s the library’s responsibility, not yours. Test what your component does, not what TanStack Query does.To be quite honest with you, that last point is the one I see violated the most. People write a test that mocks the whole useQuery composable, then assert that their component “calls useQuery with the right key.” Those tests don’t actually catch any real bugs, they just assert that the code is the code. Mock fetch, not the library.
Once you have a mountWithQuery helper, a setup file that stubs fetch and the rhythm of flushPromises, testing TanStack Query components stops feeling weird and starts feeling like regular component testing. You’re back to “render the component, simulate a thing, assert what’s in the DOM.”
For more on the testing side specifically, the official testing guide is worth a read (it’s React-flavored but the patterns translate cleanly), and the Vitest mocking docs are great if you want to go deeper on stubbing globals and inspecting mock calls.
Happy testing!
Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.