Redux
Asynchronous
Testing
JavaScript
Programming

Testing Complex Asynchronous Redux Actions

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Redux reducers are straightforward to test because they are pure functions. Async actions are harder because timing, network failures, retries, and cancellation all affect what the store should do.

Test the observable behavior

The most useful async Redux tests verify behavior at the store boundary. Given a mocked dependency and a dispatched async action, the test should prove which state transitions happen and what final state the user-facing slice ends up with.

That is more stable than asserting on middleware internals or counting low-level dispatches in a mock store. If a thunk is rewritten but still produces the same user-visible result, good tests should continue to pass.

With Redux Toolkit, dependency injection makes this straightforward. Pass the API client through the thunk extra argument so the test controls success and failure deterministically.

javascript
1import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
2
3export const fetchUser = createAsyncThunk(
4  'users/fetchUser',
5  async (id, thunkApi) => {
6    return await thunkApi.extra.api.getUser(id)
7  }
8)
9
10const usersSlice = createSlice({
11  name: 'users',
12  initialState: { status: 'idle', user: null, error: null },
13  reducers: {},
14  extraReducers: builder => {
15    builder
16      .addCase(fetchUser.pending, state => {
17        state.status = 'loading'
18        state.error = null
19      })
20      .addCase(fetchUser.fulfilled, (state, action) => {
21        state.status = 'succeeded'
22        state.user = action.payload
23      })
24      .addCase(fetchUser.rejected, (state, action) => {
25        state.status = 'failed'
26        state.error = action.error.message
27      })
28  }
29})
30
31const makeStore = api =>
32  configureStore({
33    reducer: { users: usersSlice.reducer },
34    middleware: getDefaultMiddleware =>
35      getDefaultMiddleware({ thunk: { extraArgument: { api } } })
36  })
37
38test('stores user data after success', async () => {
39  const api = { getUser: jest.fn().mockResolvedValue({ id: 7, name: 'Ada' }) }
40  const store = makeStore(api)
41
42  await store.dispatch(fetchUser(7))
43
44  expect(api.getUser).toHaveBeenCalledWith(7)
45  expect(store.getState().users).toEqual({
46    status: 'succeeded',
47    user: { id: 7, name: 'Ada' },
48    error: null
49  })
50})
51
52test('stores an error after failure', async () => {
53  const api = { getUser: jest.fn().mockRejectedValue(new Error('timeout')) }
54  const store = makeStore(api)
55
56  await store.dispatch(fetchUser(7))
57
58  expect(store.getState().users.status).toBe('failed')
59  expect(store.getState().users.error).toBe('timeout')
60})

This test setup exercises a real reducer and real middleware, while keeping the network layer fake and predictable.

Break complex workflows into testable pieces

Async Redux code becomes difficult when one thunk parses responses, transforms data, manages retries, and coordinates navigation or side effects all at once. A better design is to keep the thunk focused on orchestration and move pure logic into helpers or reducers.

Then the tests fall into place naturally. Pure transformers get unit tests. The thunk gets behavior tests. The UI can get a smaller integration test that verifies loading and error states. That split reduces brittle test setup and makes failures easier to localize.

For delayed workflows, such as debounce or retry, make time explicit. Use fake timers so the test controls when queued work runs. That avoids random waits and removes flakiness caused by real clocks.

Think in terms of user outcomes

The question behind every async action test should be simple: what should the user observe if the request succeeds, fails, or is canceled? If the answer is clear, the assertions usually become simple too. Loading flags, cached data, error messages, and retry state are worth testing. Internal helper dispatch order is usually not.

Common Pitfalls

  • Mocking the Redux store instead of exercising a real configured store with fake services.
  • Testing only the success path and ignoring failure, retry, or cancellation behavior.
  • Forgetting to await the async dispatch before asserting on state.
  • Packing parsing and business rules into the thunk instead of keeping the thunk as orchestration code.
  • Letting real network requests or real timers leak into the test suite.

Summary

  • Test async Redux logic through store behavior, not middleware internals.
  • Inject API dependencies so success and failure cases are deterministic.
  • Use a real store and assert on final slice state.
  • Split pure logic away from orchestration so tests stay focused.
  • Control time explicitly when retries, delays, or debouncing are part of the workflow.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.