Testing Request Reducer - Part 1


In this video we are going to make our Request Reducer implementation a little more robust. As we've discussed so far, there are multiple ways to achieve this goal. Potentially the methods we have already covered may very well be good enough for your circumstances.

The issue I want to address is when we have multiple requests during a single form submission. This is a problem I encountered during my Registration workflow, whereby I wanted to first send some credit card information to Stripe, who would then issue me a card token representing the customer's credit card in return. This would act as the first phase / first request.

As we have seen, whilst a request is in progress, the 'spinner' should be displayed to the end user, along with some updated button text - "Log In" changes to "Logging In...", or in the case of registration, "Register" to "Registering..." or similar.

Whilst the request to Stripe takes place, the user correctly sees the spinner and changed text.

However, when the card token is returned, the button momentarily reverted back to the default text and the spinner disappeared. Our code then triggers the second / next request, at which point the button text changes once again and the spinner is displayed. It looks and feels pretty bad.

Thankfully, fixing this is not difficult, and gives us a chance to learn a little more about testing, and cover a potential problem in unexpectedly mutating our application's state.

Test First

We will start by updating our tests to cover the new approach:

// /__tests__/reducers/requestReducer.react-test.js

import request from '../../src/reducers/requestReducer';
import {REQUEST__STARTED} from '../../src/constants/actionTypes';

describe('Request Reducer', () => {

  it('has a default state', () => {
    expect(request(undefined, { type: 'unexpected'})).toEqual({
      sendingRequest: false,
      inProgress: []
    });
  });

  it('can handle REQUEST__STARTED', () => {

    let action = {
      type: REQUEST__STARTED,
      payload: {
        requestFrom: 'some.saga'
      }
    };

    expect(request(undefined, action)).toEqual({
      sendingRequest: true,
      inProgress: ['some.saga']
    });
  });
});

Fairly straightforward here. We are going to replace the 'catch all' sendingRequest approach to instead use an array (called inProgress) to keep track of any and all requests that are in progress.

By default, when our application initialises we want this to be an empty array.

To add a request, we send in an Action containing the name of the saga (or elsewhere) that the request initiated from. This is a string, and would be better served as a constant - to stop from inevitable typos.

Adding a boolean of sendingRequest in still makes sense for convenience, as it allows us to more succinctly query the applications current state, rather than having to length check the inProgress array as needed.

This covers the easiest part of the problem - one request at once. But we have the need to track multiple requests, so let's add in a test for that scenario also:

// /__tests__/reducers/requestReducer.react-test.js

// * snip *

  it('can handle multiple instances of REQUEST__STARTED', () => {

    let action = {
      type: REQUEST__STARTED,
      payload: {
        requestFrom: 'some.saga'
      }
    };

    let newState = request(undefined, action);

    expect(newState).toEqual({
      sendingRequest: true,
      inProgress: ['some.saga']
    });

    action = {
      type: REQUEST__STARTED,
      payload: {
        requestFrom: 'another.saga'
      }
    };

    newState = request(newState, action);

    expect(newState).toEqual({
      sendingRequest: true,
      inProgress: ['some.saga', 'another.saga']
    });
  });

// * snip *

Important to note here is that for our first call to the request reducer:

let newState = request(undefined, action);

We expect to have no current state, so we fall back to the default. We've tested that, so we know that we are in a known state.

On the next call to the request reducer, we need to ensure we pass in the current state, rather than undefined:

newState = request(newState, action);

And at that point we should expect to have two requests inProgress.

Ok, so that's the desired outcome, let's cover how to get there.

Getting to Green

There's a simple approach here, and then a more involved approach. Strangely, both will show as a pass in our test suite, but one will cause redux-immutable-state-invariant to have itself a meltdown.

Ok, so let's cover off the easiest change which is to update our default state:

// /src/reducers/requestReducer.js

export default function request(state = {
  sendingRequest: false,
  inProgress: []
}, action) {

Cool, our first test: it('has a default state' should now be passing.

To get to passing for our single instance test, we can do something really simple:

// /src/reducers/requestReducer.js

import {REQUEST__STARTED, REQUEST__FINISHED} from '../constants/actionTypes';

export default function request(state = {
  sendingRequest: false,
  inProgress: []
}, action) {

  switch (action.type) {

    case REQUEST__STARTED: {
      return Object.assign({}, state, {
        sendingRequest: true,
        inProgress: [action.payload.requestFrom]
      });
    }

And if all we ever needed was an array of one request, we would be sorted.

Of course, we need to handle multiple requests, so this implementation will fail as soon as we run the third test.

This brings us on to the simple vs redux-immutable-state-invariant-friendly implementations.

Redux assumes that we never mutate the objects given to us in our reducers. This is why we use Object.assign everywhere, returning new objects composed from existing objects. Basically we create new objects by copying the interesting parts from the existing state, but never directly changing the existing state.

However, our test suite doesn't care about immutability.

As such we can write ourselves some valid JavaScript and get to green:

// /src/reducers/requestReducer.js

import {REQUEST__STARTED, REQUEST__FINISHED} from '../constants/actionTypes';

export default function request(state = {
  sendingRequest: false,
  inProgress: []
}, action) {

  switch (action.type) {

    case REQUEST__STARTED: {

      let newInProgress = state.inProgress.push(action.payload.requestFrom);

      return Object.assign({}, state, {
        sendingRequest: true,
        inProgress: newInProgress
      });
    }

And that's good, right?

Well, not so much. push will change (or mutate) the existing state.inProgress array, which violates the requirement that we never mutate existing state.

We've added redux-immutable-state-invariant to our middleware stack precisely to flag up these issues, but you would only see it if you have your developer tools console open - which you really should do whenever developing, I guess.

Anyway, fixing this is not difficult either. Rather than use push, we can instead use concat, which takes our existing array, merges it with another array, and returns a brand new array. Sounds good, but we don't have an array, right? :)

// /src/reducers/requestReducer.js

import {REQUEST__STARTED, REQUEST__FINISHED} from '../constants/actionTypes';

export default function request(state = {
  sendingRequest: false,
  inProgress: []
}, action) {

  switch (action.type) {

    case REQUEST__STARTED: {
      return Object.assign({}, state, {
        sendingRequest: true,
        inProgress: state.inProgress.concat([action.payload.requestFrom])
      });
    }

We don't have an array, but creating one is cheap :) We just wrap requestFrom in array, and then we can happily concat it with the existing state.inProgress array, which returns us a new array.

Hoorah, we now have a set of passing tests that shouldn't (never say never) lead to weird issues with unexpected state mutations further down the line.

Code For This Course

Get the code for this course.

Code For This Video

Get the code for this video.

Episodes