Saturday, June 18, 2016

Test Driven Development of a Redux Store

TLDR:

Redux is a JavaScript library that is really useful to store and manage state of a application. It has 3 important concepts:
  • The state is the current snapshot of the data.
  • A store contains the current state.
  • A reducer takes an event and produces a new snapshot of the data
Externally, Redux is based on the "Observable" pattern (the "M" of MVC) where interested objects register as being interested when the store changes state. Internally, it contains a current state that does not change and does not provide setters on the state. 

Instead, to change the state you pass an event to the redux store (like "item deleted"). The store itself then creates an entirely new state by invoking all the reducers and notifies observers.

A really useful thing from a TDD perspective is that a store can have multiple reducers. Each reducer is responsible for managing an isolated part of the state - and events are sent to all reducers. This is useful because we can test reducers independently.

The Tests

To continue the example from the React Login Page, I decided to first build a simple store for authentication data. It needed to store a boolean "loggedIn" flag and an authentication token. After a bit of thought, I decided on the following tests:
  • Default value of logged in and token flags
  • Processing a "loggedIn" event should set the flag and token
  • Processing a "loggedOut" event should set the flag and remove the token.
  • The state is immutable
(note that each of those is actually two tests).

For those who are interested, my imports are:
import { createStore } from 'redux';
import reducer, {createLoginAction, createLogoutAction} from "../../src/model/authenticationReducer.jsx";

The rationale behind those imports is:
  • I want to export the reducer by default - not a created store. 
    • This lets me combine the reducer into a bigger store elsewhere in my application. 
    • It also means that I have to create the store in my app - so that the module can't return a hacked store. It has to return a proper reducer.
    • Finally, it means I don't have to test that the store being returned does the event notification properly.
  • I felt that the methods to create the login and logout actions belonged in the same file as the reducer.
    • That way I can have private constants for the events and all logic is in the same place. 
    • It also means my tests don't need to know the internals of the events.
In addition, I used the javascript "Immutable" library as it makes copying an entire state easy.

Default Values

This test is reasonably trivial - but important. By default the user should be logged out.

it("should be logged out by default", () => {
  var store = createStore(reducer);
  expect(store.getState().get("loggedIn")).toEqual(false);
  expect(store.getState().get("token")).toBeUndefined();
});

Logging In

Again, this is a reasonably trivial test.

it("should update the logged in flag and the token on an incoming 'login' event", () => {
  var store = createStore(reducer);
  store.dispatch(createLoginAction("12345"));

  expect(store.getState().get("loggedIn")).toEqual(true);
  expect(store.getState().get("token")).toEqual("12345");
});

Logging Out

Logging out was marginally more interesting because I had to get the store into a logged in state first. While I don't like calling the login method - because it means that a broken login could break this test - it seemed the cleanest way to achieve my need.

it("should update the logged in flag and the token on an incoming 'logout' event", () => {
  var store = createStore(reducer);

  // have to log in first!
  store.dispatch(createLoginAction("12345"));

  store.dispatch(createLogoutAction());

  expect(store.getState().get("loggedIn")).toEqual(false);
  expect(store.getState().get("token")).toBeUndefined();
});

Immutability

One of the principles of Redux is that the state itself does not change - instead the reducer produces a new, modified, state. To test this, I changed the "modified" test to keep a reference to the store post-login then check the state has not changed after logout.

it("should not modify the previous state", () => {

  // have to log in first!
  store.dispatch(createLoginAction("12345"));
  var loggedInState = store.getState();

  store.dispatch(createLogoutAction());

  expect(store.getState().get("loggedIn")).toEqual(false);
  expect(store.getState().get("token")).toBeUndefined();
      
  expect(loggedInState.get("loggedIn")).toEqual(true);
  expect(loggedInState.get("token")).toEqual("12345");
});

The Code

In case you are interested, here is the code for the login reducer:

import Immutable from "immutable";

const defaultState = new Immutable.Map({
  loggedIn: false
});

const LOGIN_ACTION = "authentication.LOGGED_IN";
const LOGOUT_ACTION = "authentication.LOGGED_OUT";

export default (state = defaultState, action) => {
  switch (action.type) {
    case LOGIN_ACTION:
      return state.set("loggedIn", true).set("token", action.token);

    case LOGOUT_ACTION:
      return state.set("loggedIn", false).set("token", undefined);

    default:
      // console.log("Ignoring action: " + action.type);
      return state;
  }
};

export const createLoginAction = token => {
  return {
    type: LOGIN_ACTION,
    token: token
  };
};

export const createLogoutAction = () => {
  return {
    type: LOGOUT_ACTION
  };
};



No comments: