This episode covers Redux testing strategies: testing reducers purely, testing async thunks with mocked fetch, rendering components with a test store using testing-library, and integrating MSW to mock APIs realistically without a real server.

Redux is designed to be easy to test: reducers are pure functions, and all async logic lives in thunks. Episode 18 turns that advantage into practice — you'll learn to write tests for slices, thunks, and components that use Redux, and mock the API with MSW so tests run fast and deterministically.
The testing strategy is layered: unit tests for pure reducers, integration tests for thunks with mocked fetch, then component tests with a renderer plus a test store. Together these layers keep every code change from silently breaking state behavior.
Before writing tests, prepare the tooling and a test store built from the same slices as the app:
npm install -D vitest @testing-library/react @testing-library/jest-dom mswimport { configureStore } from "@reduxjs/toolkit"
import { authSlice } from "../features/auth/authSlice"
import { postsSlice } from "../features/posts/postsSlice"
export const createTestStore = (preloaded = {}) =>
configureStore({
reducer: {
auth: authSlice.reducer,
posts: postsSlice.reducer,
},
preloadedState: preloaded,
})createTestStore is a test store factory with an injectable initial state. Every test creates a new store so no state leaks between tests.
A reducer is just a function: give it a state and an action, inspect the result. postsReducer(initialState, postAdded(...)) is an ordinary pure function call:
import { expect, it } from "vitest"
import postsReducer, { postAdded } from "./postsSlice"
it("menambahkan post baru ke state", () => {
const initialState = { ids: [], entities: {} }
const next = postsReducer(
initialState,
postAdded({ id: 1, title: "Halo" }),
)
expect(next.entities[1]?.title).toBe("Halo")
expect(next.ids).toContain(1)
})Thunks are tested by mocking the global fetch and using a test store. Dispatch the thunk, then wait for it to finish:
import { expect, it, vi } from "vitest"
import { createTestStore } from "../../test/store"
import { fetchPosts } from "./postsSlice"
it("menyimpan posts saat berhasil", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, title: "Dari API" }],
}))
const store = createTestStore()
await store.dispatch(fetchPosts())
expect(store.getState().posts.items).toHaveLength(1)
expect(store.getState().posts.status).toBe("succeeded")
vi.unstubAllGlobals()
})store.dispatch(fetchPosts()) returns the thunk promise. After it resolves, the state can be inspected directly. Add a second scenario with mockRejectedValue to make sure the failed status fires on the rejected lifecycle — together they cover the full pending, fulfilled, rejected lifecycle.
Redux components are tested by wrapping them in a Provider holding a test store:
import { render, screen, waitFor } from "@testing-library/react"
import { Provider } from "react-redux"
import { createTestStore } from "../../test/store"
import PostList from "./PostList"
it("menampilkan daftar post dari store", () => {
const store = createTestStore({
posts: { items: [{ id: 1, title: "Post Satu" }], status: "succeeded" },
})
render(
<Provider store={store}>
<PostList />
</Provider>,
)
expect(screen.getByText("Post Satu")).toBeInTheDocument()
})Because createTestStore accepts a preloaded state, a test can seed data directly without waiting for a fetch. For components using query hooks, use waitFor to wait for the cache to fill; the test store still uses the default RTK middleware — including the RTK Query middleware — so query hooks run exactly as in production.
MSW mocks at the network level: /api/... requests are intercepted and answered by handlers, rather than blocking fetch per test. Define the handlers once for the whole suite:
import { http, HttpResponse } from "msw"
export const handlers = [
http.get("*/api/posts", () => {
return HttpResponse.json([
{ id: 1, title: "Post MSW" },
{ id: 2, title: "Post Kedua" },
])
}),
http.post("*/api/posts", async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
]Handlers run for any matching URL — there's no need to touch the app code at all. MSW works for fetch, axios, and graphql-request alike.
Enable the server in the test setup:
import { setupServer } from "msw/node"
import { handlers } from "./mocks/handlers"
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())After this setup, any thunk or query hook making /api/... requests gets an MSW response. Combined with component tests, the whole layer — from component to store — is tested without a real server.
Warning
Don't mix manual fetch mocking and MSW in the same test file. Pick one strategy per layer: mock fetch for thunk unit tests, MSW for integration and component tests so the network behavior is more realistic.
Testing Redux feels light because of its pure architecture. Reducers are tested as plain functions, thunks are tested by mocking fetch against a test store, and components are tested with a renderer wrapped in Provider. MSW closes the outermost layer by mocking the API realistically, so the entire stack — action, reducer, store, component — can be verified without a server.
Key takeaways:
createTestStore so every test has isolated state.dispatch completes.waitFor for queries.In the next episode, episode 19 covers debugging — you'll use Redux DevTools for time-travel debugging, action traces, and state diffs, then troubleshoot common problems like selectors creating new objects, Immer errors, stale state, and SSR hydration issues.