Testing RTK Query: Cache Invalidation, Mutations, and Optimistic Updates
RTK Query is Redux Toolkit's built-in data fetching and caching solution. It generates hooks, reducers, and action creators from endpoint definitions — which means testing it requires a different approach than testing hand-written reducers. You need to test the API layer, cache behavior, and the mutations that invalidate it.
Setup
npm install @reduxjs/toolkit react-redux
npm install -D jest ts-jest msw @testing-library/react @testing-library/jest-dom jest-environment-jsdomRTK Query tests generally fall into two categories:
- API slice unit tests — test endpoints in isolation with a real configured store and a mocked server
- Component integration tests — test hooks rendered inside a component, verifying loading/success/error states
Define an API Slice
// services/postsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export interface Post {
id: number
title: string
body: string
userId: number
}
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: result =>
result
? [
...result.map(({ id }) => ({ type: 'Post' as const, id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: builder.query<Post, number>({
query: id => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: body => ({ url: '/posts', method: 'POST', body }),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation<void, number>({
query: id => ({ url: `/posts/${id}`, method: 'DELETE' }),
invalidatesTags: (result, error, id) => [
{ type: 'Post', id },
{ type: 'Post', id: 'LIST' },
],
}),
}),
})
export const {
useGetPostsQuery,
useGetPostQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApiSet Up MSW for Test Mocking
Mock Service Worker (MSW) intercepts actual fetch calls in tests without patching globals:
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
import type { Post } from '../services/postsApi'
const posts: Post[] = [
{ id: 1, title: 'First Post', body: 'Hello world', userId: 1 },
{ id: 2, title: 'Second Post', body: 'More content', userId: 1 },
]
export const handlers = [
http.get('/api/posts', () => HttpResponse.json(posts)),
http.get('/api/posts/:id', ({ params }) => {
const post = posts.find(p => p.id === Number(params.id))
if (!post) return new HttpResponse(null, { status: 404 })
return HttpResponse.json(post)
}),
http.post('/api/posts', async ({ request }) => {
const body = (await request.json()) as Omit<Post, 'id'>
const newPost: Post = { ...body, id: posts.length + 1 }
posts.push(newPost)
return HttpResponse.json(newPost, { status: 201 })
}),
http.patch('/api/posts/:id', async ({ params, request }) => {
const patch = (await request.json()) as Partial<Post>
const index = posts.findIndex(p => p.id === Number(params.id))
if (index === -1) return new HttpResponse(null, { status: 404 })
posts[index] = { ...posts[index], ...patch }
return HttpResponse.json(posts[index])
}),
http.delete('/api/posts/:id', ({ params }) => {
const index = posts.findIndex(p => p.id === Number(params.id))
if (index === -1) return new HttpResponse(null, { status: 404 })
posts.splice(index, 1)
return new HttpResponse(null, { status: 204 })
}),
]// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// jest.setup.ts
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Testing Query Endpoints
Create a helper to build a configured store for tests:
// testUtils.ts
import { configureStore } from '@reduxjs/toolkit'
import { postsApi } from '../services/postsApi'
export function makeStore() {
return configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(postsApi.middleware),
})
}// services/postsApi.test.ts
import { makeStore } from '../testUtils'
import { postsApi } from './postsApi'
describe('getPosts endpoint', () => {
it('fetches and caches the posts list', async () => {
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.getPosts.initiate()
)
expect(result.status).toBe('fulfilled')
expect(result.data).toHaveLength(2)
expect(result.data![0].title).toBe('First Post')
})
it('returns cached data on second call without network request', async () => {
const store = makeStore()
await store.dispatch(postsApi.endpoints.getPosts.initiate())
// Second call — hits cache
const second = await store.dispatch(
postsApi.endpoints.getPosts.initiate()
)
expect(second.status).toBe('fulfilled')
expect(second.data).toHaveLength(2)
})
it('handles 500 error gracefully', async () => {
const { http, HttpResponse } = await import('msw')
const { server } = await import('../mocks/server')
server.use(
http.get('/api/posts', () =>
HttpResponse.json({ message: 'Internal error' }, { status: 500 })
)
)
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.getPosts.initiate()
)
expect(result.status).toBe('rejected')
expect((result as any).error).toBeDefined()
})
})
describe('getPost endpoint', () => {
it('fetches a single post by id', async () => {
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.getPost.initiate(1)
)
expect(result.status).toBe('fulfilled')
expect(result.data?.id).toBe(1)
expect(result.data?.title).toBe('First Post')
})
it('returns error for unknown post id', async () => {
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.getPost.initiate(999)
)
expect(result.status).toBe('rejected')
})
})Testing Mutations
describe('createPost mutation', () => {
it('creates a post and returns it with assigned id', async () => {
const store = makeStore()
const newPost = { title: 'New Post', body: 'Content', userId: 2 }
const result = await store.dispatch(
postsApi.endpoints.createPost.initiate(newPost)
)
expect(result.data?.id).toBeDefined()
expect(result.data?.title).toBe('New Post')
})
})
describe('updatePost mutation', () => {
it('patches a post and returns updated data', async () => {
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.updatePost.initiate({ id: 1, title: 'Updated Title' })
)
expect(result.data?.title).toBe('Updated Title')
expect(result.data?.id).toBe(1)
})
})
describe('deletePost mutation', () => {
it('deletes a post without error', async () => {
const store = makeStore()
const result = await store.dispatch(
postsApi.endpoints.deletePost.initiate(1)
)
// 204 No Content — data is undefined, status is fulfilled
expect(result.error).toBeUndefined()
})
})Testing Cache Invalidation
Cache invalidation is the key behavior to verify: after a mutation, the relevant cached queries should be marked invalid and refetched on next access.
describe('cache invalidation', () => {
it('invalidates LIST tag after createPost', async () => {
const store = makeStore()
// Fetch the list — this populates the cache
await store.dispatch(postsApi.endpoints.getPosts.initiate())
const before = postsApi.endpoints.getPosts.select()(store.getState())
expect(before.data).toHaveLength(2)
// Create a post — invalidates { type: 'Post', id: 'LIST' }
await store.dispatch(
postsApi.endpoints.createPost.initiate({
title: 'Third Post',
body: 'New content',
userId: 1,
})
)
// Re-query — should trigger a network request because cache is invalid
const after = await store.dispatch(
postsApi.endpoints.getPosts.initiate(undefined, { forceRefetch: true })
)
expect(after.data).toHaveLength(3)
})
it('invalidates specific post tag after updatePost', async () => {
const store = makeStore()
// Warm the cache for post 1
await store.dispatch(postsApi.endpoints.getPost.initiate(1))
// Update post 1 — invalidates { type: 'Post', id: 1 }
await store.dispatch(
postsApi.endpoints.updatePost.initiate({ id: 1, title: 'Changed' })
)
// Force refetch — server should return updated data
const after = await store.dispatch(
postsApi.endpoints.getPost.initiate(1, { forceRefetch: true })
)
expect(after.data?.title).toBe('Changed')
})
})Testing Optimistic Updates
Optimistic updates modify the cache immediately before the server responds, then revert on error:
// services/postsApiOptimistic.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Post } from './postsApi'
export const postsApiOptimistic = createApi({
reducerPath: 'postsApiOptimistic',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: [{ type: 'Post', id: 'LIST' }],
}),
likePost: builder.mutation<Post, number>({
query: id => ({ url: `/posts/${id}/like`, method: 'POST' }),
async onQueryStarted(id, { dispatch, queryFulfilled }) {
// Optimistically increment likes
const patchResult = dispatch(
postsApiOptimistic.util.updateQueryData('getPosts', undefined, draft => {
const post = draft.find(p => p.id === id)
if (post) (post as any).likes = ((post as any).likes ?? 0) + 1
})
)
try {
await queryFulfilled
} catch {
// Revert the optimistic update on error
patchResult.undo()
}
},
}),
}),
})import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { configureStore } from '@reduxjs/toolkit'
import { postsApiOptimistic } from './postsApiOptimistic'
function makeOptimisticStore() {
return configureStore({
reducer: { [postsApiOptimistic.reducerPath]: postsApiOptimistic.reducer },
middleware: gDM => gDM().concat(postsApiOptimistic.middleware),
})
}
describe('optimistic updates', () => {
it('applies optimistic update before server response', async () => {
server.use(
http.get('/api/posts', () =>
HttpResponse.json([{ id: 1, title: 'Post', body: '', userId: 1, likes: 0 }])
),
http.post('/api/posts/:id/like', () =>
HttpResponse.json({ id: 1, title: 'Post', body: '', userId: 1, likes: 1 })
)
)
const store = makeOptimisticStore()
await store.dispatch(postsApiOptimistic.endpoints.getPosts.initiate())
// Fire mutation but don't await — check cache mid-flight
store.dispatch(postsApiOptimistic.endpoints.likePost.initiate(1))
const state = postsApiOptimistic.endpoints.getPosts.select()(store.getState())
const post = state.data?.find(p => p.id === 1)
expect((post as any)?.likes).toBe(1)
})
it('reverts optimistic update on server error', async () => {
server.use(
http.get('/api/posts', () =>
HttpResponse.json([{ id: 1, title: 'Post', body: '', userId: 1, likes: 0 }])
),
http.post('/api/posts/:id/like', () =>
new HttpResponse(null, { status: 500 })
)
)
const store = makeOptimisticStore()
await store.dispatch(postsApiOptimistic.endpoints.getPosts.initiate())
// Await the mutation so error handling runs
await store.dispatch(postsApiOptimistic.endpoints.likePost.initiate(1))
const state = postsApiOptimistic.endpoints.getPosts.select()(store.getState())
const post = state.data?.find(p => p.id === 1)
expect((post as any)?.likes).toBe(0)
})
})Testing with React Testing Library
// components/PostList.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
import { makeStore } from '../testUtils'
import PostList from './PostList'
function renderWithStore(ui: React.ReactElement) {
const store = makeStore()
return render(<Provider store={store}>{ui}</Provider>)
}
describe('PostList component', () => {
it('shows loading state initially', () => {
renderWithStore(<PostList />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('renders posts after fetch completes', async () => {
renderWithStore(<PostList />)
await waitFor(() => {
expect(screen.getByText('First Post')).toBeInTheDocument()
expect(screen.getByText('Second Post')).toBeInTheDocument()
})
})
it('shows error message on fetch failure', async () => {
server.use(
http.get('/api/posts', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
)
renderWithStore(<PostList />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
})Validation at Scale with HelpMeTest
RTK Query tests cover the data layer, but verifying the full user experience — loading spinners disappearing at the right moment, error states being dismissed after retry — requires browser-level tests. HelpMeTest runs these in plain English:
Go to /posts
Verify loading spinner is visible
Wait for posts list to appear
Verify "First Post" is shown
Click "Delete" on "First Post"
Verify "First Post" is no longer in the listThese tests catch race conditions and UI bugs that RTK Query unit tests can't reach.
Summary
RTK Query testing strategy:
- Use MSW to mock the server — tests stay realistic without patching globals
- Dispatch
initiate()directly to test endpoint behavior without mounted components - Test cache invalidation explicitly —
forceRefetch: truelets you verify the cache was actually cleared - Test all three optimistic paths: apply, server success (stays), server error (reverts)
- Add component-level tests with React Testing Library for loading/error/success states
The key insight: RTK Query caching logic lives in the Redux store, not in React. Most of your tests should target the store directly, using the generated initiate() and select() utilities.