Testing RTK Query: Cache Invalidation, Mutations, and Optimistic Updates

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-jsdom

RTK Query tests generally fall into two categories:

  1. API slice unit tests — test endpoints in isolation with a real configured store and a mocked server
  2. 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,
} = postsApi

Set 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 list

These 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 explicitlyforceRefetch: true lets 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.

Read more