Testing Redux Store

November 14, 2018

By Gleb Bahmutov

Cypress tests usually operate via public browser APIs like DOM, network, or storage. But the tests can just as easily reach into the application and check if the internal state is updated correctly. In this blog post I will show you how to run assertions against a React application that uses a Redux store.

This blog post assumes you’ve already installed Cypress, and you can find the source code in cypress-example-recipes#testing-redux-store.

First test

I have copied the official Redux TodoMVC example from reduxjs/redux/tree/master/examples/todomvc. This application runs on port 3000, so I set this as our base url in my cypress.json file.

{
  "baseUrl": "http://localhost:3000"
}

The first test just confirms that the application loads, has an input field and 1 todo.

// cypress/integration/spec.js
it('loads', () => {
  cy.visit('/')
  cy
  .focused()
  .should('have.class', 'new-todo')
  .and('have.attr', 'placeholder', 'What needs to be done?')
  cy.get('.todo-list li').should('have.length', 1).contains('Use Redux')
})

Access Redux store

Great, now let’s expose the application’s store - but only if the application is running inside our Cypress tests. In my src/index.js file, let’s add an if (window.Cypress) block:

const store = createStore(reducer)

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

// expose store when run in Cypress
if (window.Cypress) {
  window.store = store
}

Now open your DevTools console in the browser while running Cypress. Within your DevTools, switch to “Your App” context using the drop down (underlined in the image below) and you should be able to get to the Redux store and see its current state.

If we can get to the store from the DevTools console - we can get to it from our Cypress test. We just need to get the reference to the application’s window context. We can do this using the cy.window() command. From the yielded window object we can grab the store property and invoke getState to get the current state object.

Let’s confirm that the store has the expected state when the application loads.

it('has expected state on load', () => {
  cy.visit('/')
  cy.window().its('store').invoke('getState').should('deep.equal', {
    todos: [
      {
        completed: false,
        id: 0,
        text: 'Use Redux',
      },
    ],
    visibilityFilter: 'show_all',
  })
})

The test passes and if we click on the INVOKE .getState() command in the Command Log, we can inspect the dumped state object in the DevTools console. Most of Cypress’s commands print additional information to the console when you click on the Command Log.

Drive using the DOM

We can drive the application using the DOM and then assert the state changes as we expect. Let us add a second todo item, mark it as completed, change the filter and then check the store.

it('is updated by the DOM actions', () => {
  cy.visit('/')
  cy.focused().type('Test with Cypress{enter}')
  cy.contains('li', 'Test with Cypress').find('input[type=checkbox]').click()
  cy.contains('.filters a', 'Completed').click()
  cy.window().its('store').invoke('getState').should('deep.equal', {
    todos: [
      {
        completed: false,
        id: 0,
        text: 'Use Redux',
      },
      {
        completed: true,
        id: 1,
        text: 'Test with Cypress',
      },
    ],
    visibilityFilter: 'show_completed',
  })
})

Pro tip 💡: Cypress tests run very quickly. To see what is going on while working on a test, add a .pause() command to the test.

A problem

Let’s take a look at the above test again. We are going to enter a couple of items using the application’s user interface and then check that the right number of items are in the store.

it('can wait for delayed updates', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}')
  cy.window().its('store').invoke('getState').its('todos').should('have.length', 3)
})

There is a subtle race condition possible in the above code. Let’s say that our application handles events with a slight delay - which is possible in a typical application. Maybe the app is sending each new item to the server to be saved. In our application code we will simulate the delay by changing the Header component.

// src/components/Header.jsx
// change from this
const Header = ({ addTodo }) => (
  <header className="header">
    <h1>todos</h1>
    <TodoTextInput
      newTodo
      onSave={(text) => {
        if (text.length !== 0) {
          addTodo(text)
        }
      }}
      placeholder="What needs to be done?"
    />
  </header>
)
// to this
const Header = ({ addTodo }) => (
  <header className="header">
    <h1>todos</h1>
    <TodoTextInput
      newTodo
      onSave={(text) => {
        if (text.length !== 0) {
          setTimeout(addTodo, 1000, text)
        }
      }}
      placeholder="What needs to be done?"
    />
  </header>
)

The only change was to delay calling the addTodo action by 1 second from addTodo(text) to setTimeout(addTodo, 1000, text). Will our test break? Yes, watch it fail in the video below.

Notice the order of events in the command log:

  1. Type “first”.
  2. Type “second”.
  3. Get window reference.
  4. Get store property from the window object.
  5. Invoke getState.
  6. Get todos starts spinning.
  7. Items “first” and “second” appear in the list.
  8. Get todos command and assertion should('have.length', 3) fail after a few seconds.

In Cypress almost all assertions do NOT fail immediately. Instead, a failing assertion tells Cypress to go back to the immediate previous command and rerun it - and then checks the assertion again. Still failing? Rerun the command and check again! This continues for up to 4 seconds by default. After the 4 second defaultCommandTimeout ends with the assertion still failing, the command fails. This built-in command retry-ability is the core feature of Cypress. But not all commands in the test are retried - only the last one; and not every command can be retried: cy.get() and .its() can be safely retried, while .type() and .invoke() change the application and thus are NOT retried.

I explain command retries in my “End-to-end Testing Is Hard - But It Doesn’t Have to Be” presentation at ReactiveConf 2018: video, slides.

Observing the DOM

We can solve the above race condition in two ways. First, we can make our test wait for the application to actually process the action by observing an external effect - the DOM mutation for example. In our test, we grabbed the reference to the store and its state object too early, before the application had a chance to actually set the new state. We are holding onto a stale state object reference returned by the invoke('getState') call - it will never get updated, even when the application’s internal store gets the new items.

We need to delay getting the state object reference; we need to get it after the application updates it. We can tell when the application does add the items to the store - it happens when the DOM is updated! So to make our test more robust, we need to assert the number of visible items in the DOM (which can use built-in retries), and then get the Redux store.

it('can wait for delayed updates', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}')
  // check the dom
  cy.get('.todo-list li').should('have.length', 3)
  // now redux store should have been updated
  cy.window().its('store').invoke('getState').its('todos').should('have.length', 3)
})

Take a look at the passing test now.

Only when the DOM is updated (when the command and assertion cy.get('.todo-list li').should('have.length', 3) passes) will the next command cy.window() start running - and by then the store has been updated. Whenever a test is flaky, make the test observe an external change from the application. It could be a DOM update, a changed storage, or a network request. Then check the application’s internal data - it should be ready by then.

Retrying custom code

We can solve the race condition in another way - by retrying the entire chain of code that gets us the list of todos, instead of just retrying the last command its('todos'). First, replace cy.window().its('store').invoke('getState').its('todos') with a custom function inside a .then() callback. The test is still failing, but this is a start

it('can wait for delayed updates using pipe', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}')
  const getTodos = (win) =>
    win.store.getState().todos
  cy.window().then(getTodos).should('have.length', 3)
})

Inside getTodos we are doing the same actions as before, but without using the Cypress commands, instead we are using plain JavaScript. The test is failing, because .then() is NOT retried. Cypress has no idea if the user callback is safe to retry! To really retry, you need to bring a user plugin called cypress-pipe by Nicholas Boll.

Nicholas Boll is awesome and has written most of the Cypress TypeScript definitions.

Install cypress-pipe using npm i -D cypress-pipe and add it to the cypress/support/index.js

import 'cypress-pipe';

This will add a new command .pipe() that can replace .then() and will be retried!

it('can wait for delayed updates using pipe', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}')
  const getTodos = (win) =>
    win.store.getState().todos
  // using cypress-pipe the "getTodos" will be retried until
  //   should('have.length', 3) passes
  //    or
  //   default command timeout ends
  cy.window().pipe(getTodos).should('have.length', 3)
})

The test passes after getTodos returns a list with 3 items. Notice in the animation how the command log flashes “Array(2)” before passing with the “Array(3)” assertion.

Ok, let us remove the artificial action delay from the application’s code before proceeding to the next section.

Dispatch actions

We do not have to just drive the application via DOM events. We can dispatch Redux actions directly, and then check if the DOM has been updated correctly. We just need to know what actions our application expects. In src/reducers/todos.js, I see the following code:

import {ADD_TODO, ...} from '../constants/ActionTypes'

export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: action.text
        }
      ]
  ...

and in src/constants/ActionTypes.js I see that ADD_TODO is just a string export const ADD_TODO = 'ADD_TODO'. Great, let’s write a test using the .invoke() command.

it('can drive app by dispatching actions', () => {
  cy.visit('/')
  // dispatch Redux action
  cy
  .window()
  .its('store')
  .invoke('dispatch', { type: 'ADD_TODO', text: 'Test dispatch' })
  // check if the app has updated its UI
  cy.get('.todo-list li').should('have.length', 2).contains('Test dispatch')
})

Great, the DOM is in fact updated and shows the new text.

Share code

Hmm, I do not want to duplicate actions in my specs, what if the constant ADD_TODO changes? It would be nice to use the actions already defined in the application code file src/actions/index.js.

import * as types from '../constants/ActionTypes'

export const addTodo = text => ({ type: types.ADD_TODO, text })
export const deleteTodo = id => ({ type: types.DELETE_TODO, id })
...

Cypress includes a code bundler, so we can just import these constants from the spec file.

import { addTodo, deleteTodo } from '../../src/actions'

it('can use actions from application code', () => {
  cy.visit('/')
  cy.window().its('store').invoke('dispatch', addTodo('Share code'))
  cy.window().its('store').invoke('dispatch', deleteTodo(0))
  cy.get('.todo-list li').should('have.length', 1).contains('Share code')
})

Let’s keep our code DRY and avoid duplicate commands. It is just JavaScript, and the test code should be as well engineered as the application code.

import { addTodo, deleteTodo } from '../../src/actions'
const dispatch = action => cy.window().its('store').invoke('dispatch', action)

it('can use actions from application code', () => {
  cy.visit('/')
  dispatch(addTodo('Share code'))
  dispatch(deleteTodo(0))
  cy.get('.todo-list li').should('have.length', 1).contains('Share code')
})

The spec code above looks imperative with dispatch ... calls, but all the Cypress commands are running one by one, and only if the previous command finishes successfully. I explain this declarative syntax and command chaining in the second half of the “End-to-end Testing Is Hard - But It Doesn’t Have to Be” ReactiveConf 2018 presentation video, slides.

A good start

Always starting our test with the same hardcoded todo “Use Redux” is weird. Let us change our application code to allow passing different initial store. In src/reducers/todos.js we will replace the code…

const initialState = [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]

export default function todos(state = initialState, action) {
  ...

…with a conditional initializer:

const initialState = (window.Cypress && window.initialState) || [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]

We can change our cy.visit() to pass us a window reference before the application loads. That’s a great opportunity to set the initial state.

it('can set initial todos', () => {
  cy.visit('/', {
    onBeforeLoad: win => {
      win.initialState = [
        {
          id: 0,
          text: 'first',
          completed: true
        },
        {
          id: 1,
          text: 'second',
          completed: false
        },
        {
          id: 2,
          text: 'third',
          completed: true
        }
      ]
    }
  })
  // there should be 3 items in the UI
  cy.get('.todo-list li').should('have.length', 3)
  // and 2 of them should be completed
  cy.get('.todo-list li.completed').should('have.length', 2)
})

Again, inlining a complex state object in each test hurts test readability. We can place it into a fixture JSON file instead, for example into cypress/fixtures/3-todos.json

[
  {
    "id": 0,
    "text": "first",
    "completed": true
  },
  {
    "id": 1,
    "text": "second",
    "completed": false
  },
  {
    "id": 2,
    "text": "third",
    "completed": true
  }
]

In the test we can use the cy.fixture() command to load this JSON file.

it('can set initial todos from a fixture', () => {
  cy.fixture('3-todos').then(todos => {
    cy.visit('/', {
      onBeforeLoad: win => {
        win.initialState = todos
      }
    })
  })
  // there should be 3 items in the UI
  cy.get('.todo-list li').should('have.length', 3)
  // and 2 of them should be completed
  cy.get('.todo-list li.completed').should('have.length', 2)
})

Again, this is just JavaScript, and if we expect a lot of tests to set different initial states, we should factor it out into a separate utility function.

const initialVisit = (url, fixture) => {
  cy.fixture(fixture).then(todos => {
    cy.visit(url, {
      onBeforeLoad: win => {
        win.initialState = todos
      }
    })
  })
}

it('can set initial todos from a fixture', () => {
  initialVisit('/', '3-todos')
  // there should be 3 items in the UI
  cy.get('.todo-list li').should('have.length', 3)
  // and 2 of them should be completed
  cy.get('.todo-list li.completed').should('have.length', 2)
})

Works the same way.

Snapshot testing

I have described in the blog post End-to-End Snapshot Testing my take on snapshot testing. But for this post I recommend trying out the meinaart/cypress-plugin-snapshots plugin instead.

I have installed this plugin from my terminal.

npm i -D cypress-plugin-snapshots

Then I load the plugin within the cypress/plugins/index.js file…

const { initPlugin } = require('cypress-plugin-snapshots/plugin')
module.exports = (on, config) => {
  initPlugin(on, config)
  return config
}

…and add a custom command in cypress/support/index.js

import 'cypress-plugin-snapshots/commands'

I also need to create at least an empty configuration in cypress.json file.

{
  "baseUrl": "http://localhost:3000",
  "ignoreTestFiles": [
    "**/*.snap",
    "**/__snapshot__/*"
  ],
  "env": {
    "cypress-plugin-snapshots": {}
  }
}

Here is my test - drive the app via the DOM and then expect the Redux store to match the snapshot:

it('snapshots', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}')
  cy.contains('.todo-list li', 'second').find('input[type=checkbox]').click()
  cy.contains('.filters a', 'Completed').click()
  cy.window().its('store').invoke('getState').toMatchSnapshot()
})

The very first time the test runs, the snapshot is saved into the file cypress/integration/__snapshots__/spec.js.snap and it contains the state object.

{
  "snapshots #0": {
    "todos": [
      {
        "completed": false,
        "id": 0,
        "text": "Use Redux"
      },
      {
        "completed": false,
        "id": 1,
        "text": "first"
      },
      {
        "completed": true,
        "id": 2,
        "text": "second"
      }
    ],
    "visibilityFilter": "show_completed"
  }
}

If the test runs again, the state object is compared against the saved object. Usually the snapshot matches, and the test passes.

But what happens if the Redux store has a different value for some reason? Let us change the test and find out. For example, let us add another todo item with the text “third”.

it('snapshots', () => {
  cy.visit('/')
  cy.focused().type('first{enter}').type('second{enter}').type('third{enter}')
  cy.contains('.todo-list li', 'second').find('input[type=checkbox]').click()
  cy.contains('.filters a', 'Completed').click()
  cy.window().its('store').invoke('getState').toMatchSnapshot()
})

No worries, click the “Compare Snapshot” error box. A modal pops up that shows the difference between the previously saved snapshot and the current value.

Since we have modified the test and the new value is the new “gold” value, click the “Update Snapshot” button and the new value will be written into the snapshot file. All green now, bravo Meinaart van Straalen for the excellent developer experience!

See also

Testing Vue web applications with Vuex data store & REST backend

Plugins: cypress-pipe, cypress-plugin-snapshots

“End-to-end Testing Is Hard - But It Doesn’t Have to Be” ReactiveConf 2018 presentation video, slides