my face
paulgray.net

Testing for Race Conditions with Redux

This post will explain how to write unit tests for race conditions in a redux reducer.

November 29, 2017
javascriptfront endreduxprogramming

One type of error that causes bugs is race conditions. They are often hard to detect and reproduce since they only happen in special circumstances. If you're using redux to store your state, it's easy to write unit tests that account for these special circumstances.

Let's look at an example.

The search results screen

Suppose we have a long list of items which is also filterable by a search text input. These search results are served by an api endpoint, at most 20 results at a time.

We have three actions defined to model this, an action that's dispatched whenever the user updates the text in the input:

{
  "type": "UPDATED_SEARCH",
  "payload": "redu"
}

And one action that's emitted when the search request gets resolved:

{
  "type": "RECEIVE_SEARCH_RESULTS",
  "payload": {
    "items": [ ... ]
  }
}

We have a simple reducer that returns a loading state when it receives an UPDATED_SEARCH action, and the loaded results state when it receives a RECEIVE_SEARCH_RESULTS action.

Let's write a unit test for this reducer and see how it turns out.

Writing a unit test for your reducer.

The functions that define how state changes in redux are called reducers due to their signature (the shape of a function's arguments and return value). If you take a look at how array.reduce is defined, it takes the previous sum, the current item, and returns the next sum.

const endValue = [1, 2, 3, 4].reduce((prevSum, currentItem) => {
  return prevSum + currentItem;
});
endValue // 10

Here, the sum of the numbers is considered the 'sum' of the reduce.

The signature of the lambda here is exactly like that of a redux reducer. It takes a previous state (previous sum), an action (current item), and applies that action to the previous state, returning the next state:

const searchReducer = (prevState, action) => {
  if(action.type === 'UPDATED_SEARCH'){
    return { loading: true };
  } else if(action.type === 'RECEIVE_SEARCH_RESULTS') {
    return {
      loading: false,
      items: action.payload.items
    };
  } else {
    return state;
  }
}

We can use our reducers in the same way if we think of the current state of our app as the "sum" of the reducer. If we have an array of actions, we could call reduce using our reducer, where the result would be the final state of our app after those actions are applied.

For example, Let's suppose our user came in and typed 'Red' (searchAction1), waited for some results (resultsAction1), and then refined their search to 'Redux' (searchAction2), and waited for more results (resultsAction2).

If we had variables for each of those actions, we can compute what the state should look like after those actions have been applied:

const endState = [
  searchAction1,
  resultsAction1,
  searchAction2,
  resultsAction2
].reduce(searchReducer)

// should contain the items from the second result
endState.items

The race condition

You've probably already noticed out the potential issue with our implementation. If not, suppose what would happen if the first search request took a very long time to resolve; so long, that the user didn't wait for the results and typed in a more exact query which returned before the first request did.

Here's a graphic of the request timing:

race_timing

We can write another unit test for this case just by re-ordering our events:

const endState = [
  searchAction1,
  searchAction2,
  resultsAction2,
  resultsAction1   <-- first results come in after second results
].reduce(searchReducer)

// should still contain the items from the resultsAction2
endState.items

The interface at this point should show the results from resultsAction2, but if we inspect the state of our app, we notice it contains the results of resultAction1!

This is known as a race condition, and it would be hard to detect since it only occurs when the results of the first request are returned after a subsequent request.

The solution

One simple way would be to attach timestamps to the UPDATE_FILTER and the corresponding RECEIVE_SEARCH_RESULTS action, and only apply actions with timestamps after the last applied action.

It'd be a good idea to create one action creator that combines these actions together. In practice this would look like:

function updateSearch(searchTerm) {
  const timestamp = new Date();
  return dispatch => {
    dispatch({
	  type: "UPDATED_SEARCH",
	  payload: {
	    searchTerm: searchTerm,
	    timestamp: timestamp
	  }
	});
	
	getResults('/api/search')
	  .then(results => {
	    dispatch({
		  type: "RECEIVE_SEARCH_RESULTS",
		  payload: {
		    items: results,
		    timestamp: timestamp
		  }
		})
	  ))
  }
}

Then, our reducer could be updated to simply discard actions which come with a timestamp before the most recent one:

function actionIsValid(state, action) {
  return action.payload.timestamp >= state.timestamp;
}
const searchReducer = (prevState, action) => {
  if(action.type === 'UPDATED_SEARCH' && actionIsValid(state, action)){
    return {
      loading: true,
      timestamp: action.payload.timestamp
    };
  } else if(action.type === 'RECEIVE_SEARCH_RESULTS' && actionIsValid(state, action)) {
    return {
      loading: false,
      timestamp: action.payload.timestamp
      items: action.payload.items
    };
  } else {
    return state;
  }
}

We re-run our tests, and they both pass!

The next time you're writing a unit test for a reducer, remember to include all the possible action sequences that could happen, instead of just the ones you expect to happen, and you'll prevent a lot of obscure timing-related bugs.