TanStack Query Testing With MSW and React
TanStack Query (formerly React Query) is a data-fetching library that manages API responses, caching, background refetching, and request deduplication. Testing TanStack Query with MSW requires understanding how the library's cache works—specifically, how staleTime, cacheTime, and query invalidation affect when the API is called. MSW intercepts the actual network requests, so your tests can verify both the network behavior and the cache behavior together.
At Velocity Partners, integrating TanStack Query eliminated manual data-fetching logic, but testing it revealed new challenges: queries were cached unexpectedly, background refetches were not happening, and invalidation was not triggering refetches. MSW combined with deliberate cache-testing strategies became critical for confidence in the query layer.
Setting Up TanStack Query for Testing
TanStack Query requires a QueryClient configured with reasonable test defaults. Use a test setup that creates a fresh client per test to avoid cache pollution between tests.
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { render } from "@testing-library/react";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries in tests
gcTime: 0, // Disable caching between tests
},
},
});
}
function renderWithClient(ui) {
const testQueryClient = createTestQueryClient();
return {
...render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
),
queryClient: testQueryClient,
};
}
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user.name}</div>;
}
test("loads user data with useQuery", async () => {
server.use(
http.get("/api/users/:id", () => {
return HttpResponse.json({
id: "user-1",
name: "Alice Chen",
});
})
);
const { getByText, findByText } = renderWithClient(
<UserProfile userId="user-1" />
);
// Verify loading state
expect(getByText("Loading...")).toBeInTheDocument();
// Verify data loads
const name = await findByText("Alice Chen");
expect(name).toBeInTheDocument();
});
The createTestQueryClient() function with retry: false and gcTime: 0 ensures each test starts with an empty cache and does not retry failed requests, making test behavior predictable.
Testing Query Caching Behavior
TanStack Query caches responses based on the query key and staleTime. By default, data is considered fresh for 0ms (always stale), but you can configure staleTime to control when the library considers cached data valid.
test("uses cached data without refetching within staleTime", async () => {
let requestCount = 0;
server.use(
http.get("/api/users/:id", () => {
requestCount++;
return HttpResponse.json({ id: "1", name: "Alice" });
})
);
const testQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
gcTime: Infinity, // Keep in cache
},
},
});
const { rerender } = render(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId="user-1" />
</QueryClientProvider>
);
// First render: makes request
await screen.findByText("Alice");
expect(requestCount).toBe(1);
// Rerender same component: should use cache, not refetch
rerender(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId="user-1" />
</QueryClientProvider>
);
// Wait a moment for React Query to process
await waitFor(() => {
expect(requestCount).toBe(1); // Still only one request
});
});
test("refetches when data becomes stale", async () => {
let requestCount = 0;
server.use(
http.get("/api/users/:id", () => {
requestCount++;
return HttpResponse.json({
id: "1",
name: `User (request #${requestCount})`,
});
})
);
const testQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 100, // Very short staleTime for testing
retry: false,
gcTime: Infinity,
},
},
});
const { rerender } = render(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId="user-1" />
</QueryClientProvider>
);
// First request
await screen.findByText(/request #1/);
expect(requestCount).toBe(1);
// Wait for data to become stale
await new Promise((resolve) => setTimeout(resolve, 150));
// Trigger a re-render (or use refetch)
rerender(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId="user-1" />
</QueryClientProvider>
);
// Should refetch because data is stale
await screen.findByText(/request #2/);
expect(requestCount).toBe(2);
});
These tests verify that TanStack Query respects staleTime: it uses cached data when fresh and refetches when stale. This is critical for testing cache-dependent features.
Testing Query Invalidation
Query invalidation marks data as stale, triggering a refetch. This is commonly used after mutations to refresh data.
function UserDashboard() {
const queryClient = useQueryClient();
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const res = await fetch("/api/user");
return res.json();
},
});
const updateUserMutation = useMutation({
mutationFn: async (updates) => {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
return res.json();
},
onSuccess: () => {
// Invalidate the user query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["user"] });
},
});
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateUserMutation.mutate({ name: "Bob" })}>
Update Name
</button>
</div>
);
}
test("refetches user data after mutation", async () => {
let requestCount = 0;
server.use(
http.get("/api/user", () => {
requestCount++;
return HttpResponse.json({
name: requestCount === 1 ? "Alice" : "Bob",
});
}),
http.put("/api/user", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ ...body, updated: true });
})
);
const testQueryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
},
});
render(
<QueryClientProvider client={testQueryClient}>
<UserDashboard />
</QueryClientProvider>
);
// Initial load shows Alice
await screen.findByText("Alice");
expect(requestCount).toBe(1);
// User clicks update button
const button = screen.getByRole("button", { name: /update/i });
await userEvent.click(button);
// After mutation, data is refetched and shows Bob
await screen.findByText("Bob");
expect(requestCount).toBe(2); // One for initial, one for refetch
});
This test verifies that invalidating a query causes a refetch, ensuring your mutation handlers correctly trigger data updates.
Testing Mutation Error Handling
Mutations can fail, and components should handle errors gracefully. MSW lets you simulate mutation failures.
test("handles mutation error", async () => {
server.use(
http.put("/api/user", () => {
return HttpResponse.json(
{ error: "Validation failed: name is required" },
{ status: 400 }
);
})
);
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={testQueryClient}>
<UserDashboard />
</QueryClientProvider>
);
const button = screen.getByRole("button", { name: /update/i });
await userEvent.click(button);
// Error message should appear
const error = await screen.findByText(/validation failed/i);
expect(error).toBeInTheDocument();
});
Testing mutation errors ensures your error handling works correctly.
Testing Optimistic Updates
Optimistic updates assume a mutation will succeed and immediately update the UI, rolling back if the mutation fails. Testing this requires careful timing.
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleTodoMutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !todo.completed }),
});
return res.json();
},
onMutate: async () => {
// Optimistically update the cache
queryClient.setQueryData(["todos"], (old) =>
old.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodoMutation.mutate()}
/>
{todo.title}
</li>
);
}
test("optimistically updates UI before server confirms", async () => {
server.use(
http.get("/api/todos", () => {
return HttpResponse.json([
{ id: "1", title: "Task", completed: false },
]);
}),
http.patch("/api/todos/:id", async ({ request }) => {
// Simulate a slow server
await delay(500);
const body = await request.json();
return HttpResponse.json({ id: "1", ...body });
})
);
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
render(
<QueryClientProvider client={testQueryClient}>
<TodoList />
</QueryClientProvider>
);
const checkbox = await screen.findByRole("checkbox");
expect(checkbox).not.toBeChecked();
// Click checkbox
await userEvent.click(checkbox);
// Optimistic update: checkbox should be checked immediately
expect(checkbox).toBeChecked();
// Wait for server response
await waitFor(() => {
expect(checkbox).toBeChecked(); // Should still be checked
});
});
Optimistic update testing verifies that your UI responds instantly to user actions while the mutation is in flight, improving perceived performance.
Common TanStack Query Configuration for Tests
const testQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries
staleTime: 0, // Treat all data as stale
gcTime: 0, // Immediately discard cached data
},
mutations: {
retry: false, // Disable retries on mutations
},
},
});
This configuration ensures tests are predictable: no retries, no caching surprises, clean state between tests.
Key Takeaways
- TanStack Query caches data based on query keys and
staleTime; usegcTime: 0in tests to prevent cache pollution. - Query invalidation marks data as stale, triggering refetches; test this to verify mutations trigger data updates.
- MSW intercepts the actual network requests, allowing you to test TanStack Query's cache and refetch behavior together.
- Optimistic updates immediately update the UI before the server responds; test to verify rollback on error.
retry: falsein test configuration prevents flaky tests from automatic retries masking real issues.
Frequently Asked Questions
Why does my TanStack Query test use cached data from a previous test?
Set gcTime: 0 in your test QueryClient configuration. This immediately discards cached data after use, preventing cache pollution between tests.
How do I test background refetching?
Use waitFor() with a longer timeout to verify that a refetch happens in the background: await waitFor(() => expect(requestCount).toBe(2), { timeout: 5000 })
Can I test TanStack Query without MSW?
Yes, you can mock the queryFn directly in tests, but this does not test the actual fetch behavior or how your component interacts with the network. MSW is better for integration testing.
What if my query uses enabled: false?
Queries with enabled: false do not fetch automatically. Set enabled: true in your test or trigger the fetch manually using the refetch function returned by useQuery.