How to Test Redux Middleware in React

Testing Redux middleware is essential for ensuring that side effects, API calls, and custom action transformations behave predictably in your React applications. This article provides a straightforward guide on how to test Redux middleware, covering both isolated unit testing of the middleware function and integration testing using a mock Redux store.

Understanding the Middleware Signature

To test Redux middleware, you must first understand its curried signature. Redux middleware is structured as three nested functions:

const middleware = store => next => action => { /* ... */ };

By exploiting this structure, you can test middleware either by invoking these nested functions manually (isolated unit testing) or by using a mock store library (integration testing).


Method 1: Isolated Unit Testing (No External Libraries)

Isolated unit testing is the cleanest way to test middleware because it does not require configuring a real or mock Redux store. You simply mock the store, next, and action arguments.

The Middleware to Test

Here is a simple middleware that adds a timestamp payload to specific actions:

// timestampMiddleware.js
export const timestampMiddleware = store => next => action => {
  if (action.type === 'ADD_TODO') {
    const actionWithTimestamp = {
      ...action,
      meta: { ...action.meta, timestamp: Date.now() }
    };
    return next(actionWithTimestamp);
  }
  return next(action);
};

The Unit Test

Using Jest, you can create mock functions for store and next to assert that the middleware behaves correctly.

// timestampMiddleware.test.js
import { timestampMiddleware } from './timestampMiddleware';

describe('timestampMiddleware', () => {
  let mockStore;
  let mockNext;

  beforeEach(() => {
    mockStore = {
      getState: jest.fn(() => ({})),
      dispatch: jest.fn()
    };
    mockNext = jest.fn();
  });

  it('should pass unmodified action if type is not ADD_TODO', () => {
    const action = { type: 'SOME_OTHER_ACTION' };
    
    // Invoke the curried middleware function
    timestampMiddleware(mockStore)(mockNext)(action);

    expect(mockNext).toHaveBeenCalledWith(action);
  });

  it('should add a timestamp to ADD_TODO actions', () => {
    const action = { type: 'ADD_TODO', payload: 'Buy milk' };
    
    // Freeze time to test the timestamp accurately
    const mockTime = 1600000000000;
    jest.spyOn(Date, 'now').mockImplementation(() => mockTime);

    timestampMiddleware(mockStore)(mockNext)(action);

    expect(mockNext).toHaveBeenCalledWith({
      type: 'ADD_TODO',
      payload: 'Buy milk',
      meta: { timestamp: mockTime }
    });

    jest.restoreAllMocks();
  });
});

Method 2: Integration Testing with redux-mock-store

If your middleware dispatches new actions or interacts heavily with the store’s state, testing with a mock store provides a more realistic test environment.

The Middleware to Test

Consider a middleware that fetches data from an API when a specific action is dispatched:

// apiMiddleware.js
export const apiMiddleware = store => next => async action => {
  if (action.type === 'FETCH_USER_REQUEST') {
    next(action); // Pass the request action along
    try {
      const response = await fetch(`/api/user/${action.payload}`);
      const data = await response.json();
      return store.dispatch({ type: 'FETCH_USER_SUCCESS', payload: data });
    } catch (error) {
      return store.dispatch({ type: 'FETCH_USER_FAILURE', error: error.message });
    }
  }
  return next(action);
};

The Integration Test

Using redux-mock-store, you can configure a mock store with your middleware and assert against the array of actions dispatched during the test execution.

// apiMiddleware.test.js
import configureMockStore from 'redux-mock-store';
import { apiMiddleware } from './apiMiddleware';

// Recreate the mock store creator with the middleware applied
const mockStoreCreator = configureMockStore([apiMiddleware]);

describe('apiMiddleware', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('dispatches FETCH_USER_SUCCESS on successful API call', async () => {
    const mockUser = { id: 1, name: 'John Doe' };
    
    // Mock the global fetch implementation
    global.fetch.mockResolvedValueOnce({
      json: async () => mockUser,
    });

    const store = mockStoreCreator({});

    // Dispatch the trigger action
    await store.dispatch({ type: 'FETCH_USER_REQUEST', payload: 1 });

    const actions = store.getActions();
    
    // Expect the initial request and the success action to have been dispatched
    expect(actions[0]).toEqual({ type: 'FETCH_USER_REQUEST', payload: 1 });
    expect(actions[1]).toEqual({ type: 'FETCH_USER_SUCCESS', payload: mockUser });
  });

  it('dispatches FETCH_USER_FAILURE on failed API call', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network Error'));

    const store = mockStoreCreator({});

    await store.dispatch({ type: 'FETCH_USER_REQUEST', payload: 1 });

    const actions = store.getActions();

    expect(actions[0]).toEqual({ type: 'FETCH_USER_REQUEST', payload: 1 });
    expect(actions[1]).toEqual({ type: 'FETCH_USER_FAILURE', error: 'Network Error' });
  });
});