DEV Community

loading...

Unit testing Vuex data store using Cypress.io Test Runner

bahmutov profile image Gleb Bahmutov ・12 min read

Cypress Test Runner has become a very popular tool for writing end-to-end tests, but did you know it can also run unit tests in a real browser? This post shows how to unit test your typical front end code, like the Vuex data store. The post largely follows the example from official Vuex testing page, and you can find all source code in the bahmutov/test-vuex-with-cypress repo.

Setup

For our example, all we need are Vue and Vuex libraries and Cypress. Install them using NPM commands

npm install --save vue vuex
npm install --save-dev cypress

or Yarn commands

yarn add vue vuex
yarn add -D cypress

Cypress includes Mocha test runner, Electron browser, Chai assertions, and many other tools - we should be good to go without installing anything else.

Testing mutations

The first example I would like to test is a counter that starts at zero and increments the state via a mutation. Here is source file src/counter.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const state = {
  count: 0,
}
export const mutations = {
  increment(state) {
    state.count++
  },
}

export default new Vuex.Store({
  state,
  mutations,
})

My spec (test) file will reside in cypress/integration/counter-spec.js and uses the familiar BDD syntax.

import { mutations } from '../../src/counter'

describe('mutations', () => {
  context('increment', () => {
    const { increment } = mutations
    it('INCREMENT', () => {
      const state = { count: 0 }
      increment(state)
      // see https://on.cypress.io/assertions
      // for "expect" examples
      expect(state.count).to.equal(1)
    })
  })
})

The source files are structured like this:

test-vuex-with-cypress/
  package.json
  src/
    counter.js
  cypress/
    integration/
      counter-spec.js
  cypress.json

Open Cypress with npx cypress open or yarn cypress open command and click on counter-spec.js.

Selecting spec file to run

The test should pass.

First passing unit test

Cypress watches all loaded files - and it automatically reruns the tests when a file changes. In the screen recording below I have my text editor on the left and Cypress on the right. As I keep coding and saving, I am watching the tests pass and fail - a true test-driven development experience.

Running unit tests using Cypress

The iframe on the right stays empty - because our test does not visit a website. But we can take the full advantage of the Command Log column on the left side of the Test Runner. Currently, it is showing a single passing assertion. Let's change our assertions slightly - I will use cy.wrap command to wrap the state object and yield it to the increment function, and then check the value of the property count using should(...) BDD assertion.

import { mutations } from '../../src/counter'

describe('mutations', () => {
  context('increment', () => {
    const { increment } = mutations
    it('increments the state', () => {
      // wrapped state object will be passed
      // to the "increment" callback
      // which will return new object
      cy.wrap({ count: 0 })
        .then(increment)
        // and the next assertion will run against
        // the updated state
        .should('have.property', 'count', 1)
    })
  })
})

The test passes - and because Cypress "knows" about the wrapped value, it can show it in the Command Log.

Wrapping state using cy.wrap

Nice. The assertions expect(...).to.equal and wrap(...).should('equal') are equivalent, and everything depends on what you find more readable and useful in the GUI.

If our mutation expects a payload after the state argument like

export const mutations = {
  increment(state, n = 1) {
    state.count += n
  },
}

We can pass it explicitly

it('increments by 5', () => {
  cy.wrap({ count: 0 })
    .then(state => increment(state, 5))
    .should('deep.equal', { count: 5 })
})

Testing store

Let's try testing the entire store.

import store from '../../src/counter'
describe('store commits', () => {
  it('starts with zero', () => {
    // it is a good idea to add assertion message
    // as the second argument to "expect(value, ...)"
    expect(store.state.count, 'initial value').to.equal(0)
    store.commit('increment', 4)
    expect(store.state.count, 'changed value').to.equal(4)
  })
})

As we write more test we quickly discover one potential landmine - the imported store object is a singleton!

describe('store commits', () => {
  it('starts with zero', () => {
    // it is a good idea to add assertion message
    // as the second argument to "expect(value, ...)"
    expect(store.state.count, 'initial value').to.equal(0)
    store.commit('increment', 4)
    expect(store.state.count, 'changed value').to.equal(4)
  })

  it('starts with zero again', () => {
    expect(store.state.count, 'initial value').to.equal(0)
  })
})

Second test starts with state left by the first test

At Cypress we strongly believe that every test should be independent of any other test; that each test should set its own initial data. To avoid singletons we can change our Counter code to return a factory function instead of the store object. Every call to the factory function should return a brand new store, with its own separate state object.

// src/counter.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export const mutations = {
  increment(state, n = 1) {
    state.count += n
  },
}

const counterStoreFactory = () => {
  const state = {
    count: 0,
  }
  return new Vuex.Store({
    state,
    mutations,
  })
}
export default counterStoreFactory

Before each test, we create the store object in a local closure

import storeFactory from '../../src/counter'
describe('store commits', () => {
  let store

  beforeEach(() => {
    store = storeFactory()
  })

  it('starts with zero', () => {
    // it is a good idea to add assertion message
    // as the second argument to "expect(value, ...)"
    expect(store.state.count, 'initial value').to.equal(0)
    store.commit('increment', 4)
    expect(store.state.count, 'changed value').to.equal(4)
  })

  it('starts with zero again', () => {
    expect(store.state.count, 'initial value').to.equal(0)
  })
})

Now the tests pass, no matter in what order they run, if any tests are skipped with it.skip, or if an individual test is enabled exclusively using it.only modifier.

Both tests are passing now

Time-traveling debugger

When Cypress runs the commands and assertions it links the objects it operates on to each command. For example, let's assert the entire state object in the next test.

it('compares entire state object', () => {
  expect(store.state).to.deep.equal({
    count: 0,
  })
  // equivalent assertion
  cy.wrap(store)
    .its('state')
    .should('deep.equal', {
      count: 0,
    })
})

When the test passes, we can click on the wrap or its commands shown in the Command Log on the left. Then if the DevTools console is open, the Test Runner prints the arguments as a real object that you can inspect further.

Inspecting the state object

One has to be cautious when inspecting objects though - the reference points at the real object that can change later. For example, the next test checks the initial count value, then calls the increment commit mutation, and then the test checks the updated state.

it('saves objects like checkpoints', () => {
  cy.wrap(store)
    .its('state')
    .should('deep.equal', {
      count: 0,
    })
  cy.wrap(store).invoke('commit', 'increment', 10)
  cy.wrap(store)
    .its('state')
    .should('deep.equal', {
      count: 10,
    })
})

The test passes, but when we inspect the first state value, the object shows "count: 10".

State object shows 10 even if the assertion confirmed it was zero

We know the count was zero because the first assertion has passed, but when we inspect the value we see the current value. This is similar to the way console.log(object) shows the latest value in the DevTools console as the next gif demonstrates.

console.log shows the updated value

The trick I like to do to avoid any confusion is to make a clone of the state object before comparing it.

it('saves objects like checkpoints', () => {
  cy.wrap(store)
    .its('state')
    .then(Cypress._.cloneDeep)
    .should('deep.equal', {
      count: 0,
    })
  cy.wrap(store).invoke('commit', 'increment', 10)
  cy.wrap(store)
    .its('state')
    .then(Cypress._.cloneDeep)
    .should('deep.equal', {
      count: 10,
    })
})

Cypress includes Lodash library which lets us call _.cloneDeep function. This has the additional benefit of showing plain objects because we no longer deal with Vue's reactive getters and setters.

State clones and assertions

To see the full state object clone, click on the assertion message in the Command Log. The full value as it was during that instant of the test will be dumped to the DevTools console.

Asynchronous actions

Vuex store allows performing asynchronous updates via actions mechanism. Let's add an action to increment the count after one second delay.

// same mutations as before

const actions = {
  incrementAsync({ commit }, n = 1) {
    setTimeout(() => {
      commit('increment', n)
    }, 1000)
  },
}

const counterStoreFactory = () => {
  const state = {
    count: 0,
  }

  return new Vuex.Store({
    state,
    mutations,
    actions,
  })
}
export default counterStoreFactory

We cannot test the count update synchronously. The following test fails:

it('can be async (fails)', () => {
  store.dispatch('incrementAsync', 2)
  // the next assertion will fail immediately
  expect(store.state.count).to.equal(2)
})

Test fails

Typically, to test something like this, the test runner would need to be aware of the framework's details to know when the state commit has happened. But Cypress has a better framework-independent way of dealing with asynchronous operations. It has a built-in retry-ability. In essence, an idempotent command will be retried until the assertions that immediately follow it pass or the command times out. Let's rewrite the test to retry.

it('can be async (passes)', () => {
  store.dispatch('incrementAsync', 2)
  cy.wrap(store.state)  // command
    .its('count')       // command
    .should('equal', 2) // assertion
})

The command its('count') is followed by assertion should('equal', 2). Initially, the count is zero, and the assertion fails. Cypress then runs its('count') again, passing the value to the assertion. It does this repeatedly until the action incrementAsync finishes and commits updated count. The command its('count') yields 2, the assertion should('equal', 2) passes and the test completes.

Test retries until async action does its job

Tip: you can write pretty complex asynchronous tests by waiting for your own predicates using cypress-wait-until plugin.

Spying and stubbing

During testing we might want to assert that the store's methods were called (by spying on them) or even change their behavior (using stubs).

Cypress Test Runner comes with Sinon.js library bundled, let's use it. Because Vuex is implemented as a series of namespaced modules, we cannot spy on the method store.commit, instead, we need to spy on the store._modules.root.context object that has the actual commit method.

it('calls commit "increment" in the store', () => {
  cy.spy(store._modules.root.context, 'commit').as('commit')
  store.dispatch('incrementAsync', 2)
  cy.wrap(store.state)
    .its('count')
    .should('equal', 2)
  // we can also assert directly on the spies
  // thanks for Sinon-Chai bundled with Cypress
  // https://on.cypress.io/assertions#Sinon-Chai
  cy.get('@commit').should('have.been.calledOnce')
})

The test passes, and the "Spies / Stubs" table shows our spy under alias "commit" and when it was called during the test.

Spy was called

Sinon spies in Cypress play nicely with retry-ability. We don't have to rely on the state assertion to "wait" until the assertion passes.

it('spy retries assertion', () => {
  cy.spy(store._modules.root.context, 'commit').as('commit')
  store.dispatch('incrementAsync', 2)  
  store.dispatch('incrementAsync', 5)
  cy.get('@commit').should('have.been.calledTwice')
})

Waiting on the spy to be called twice

We can make the spy more precise, for example by only spying on calls made with specific arguments.

it('spies on specific call', () => {
  cy.spy(store._modules.root.context, 'commit')
    .withArgs('increment', 5)
    .as('commit5')
  store.dispatch('incrementAsync', 2)
  store.dispatch('incrementAsync', 5)
  cy.get('@commit5').should('have.been.calledOnce')
})

Spying only on commit to increment by 5

Every call to the spy is recorded, and you can click on its record to see more details in the DevTools console.

Details of the commit to increment by 5 call

Spying is a nice way to confirm the store receives the expected calls. Let's add method stubbing where we can change the behavior of actions and mutations. In the test below we will allow all mutations to go through unchanged, but whenever there is mutation to increment count by 5, we will change the call and will increment by 100 instead.

it('stubs increment commit', () => {
  // allow all mutations to go through
  // but ("increment", 5) will call our fake function
  cy.stub(store._modules.root.context, 'commit')
    .callThrough()
    .withArgs('increment', 5)
    .callsFake((name, n) => {
      // confirm we are only stubbing increments by 5
      expect(n).to.equal(5)
      // call the original method, but pass a different value
      store._modules.root.context.commit.wrappedMethod(name, 100)
    })

  store.dispatch('incrementAsync', 2)
  store.dispatch('incrementAsync', 5)

  // our stub will turn increment by 5 to increment by 100 😃
  cy.wrap(store.state)
    .its('count')
    .should('equal', 102)
})

Stubbed method test

To explore spying further, read the Cypress Spies, Stubs And Clocks guide.

Tip: you can simplify accessing commit context and created spy using test context object and local variables.

beforeEach(function() {
  store = storeFactory()
  // save Vuex context in the test's own object
  this.context = store._modules.root.context
})
it('grabs context', function() {
  // save created spy in local variable
  const commit = cy.spy(this.context, 'commit')
  store.dispatch('incrementAsync', 2)
  // use Cypress retry-ability to wait for
  // the spy to be called once
  cy.wrap(commit).should('have.been.calledOnce')
})

Local variable to access the spy

Alternatively, give the spy an alias to access it - it will give more context to the Command Log.

it('uses alias', function() {
  // save created spy as an alias
  cy.spy(this.context, 'commit').as('commit')
  store.dispatch('incrementAsync', 2)
  // use Cypress retry-ability to wait for
  // the spy to be called once
  cy.get('@commit').should('have.been.calledOnce')
})

Spy alias

Tip: Cypress automatically removes all spies and stubs before each test, so you don't have to clean them up.

Code coverage

Let's make sure we are covering our code with unit tests. I will use Cypress code coverage plugin and istanbul to instrument unit tests as described in Cypress code coverage guide. Install the plugin and instrumentation peer dependencies:

npm install --save-dev @cypress/code-coverage \
  nyc istanbul-lib-coverage babel-plugin-istanbul
+ istanbul-lib-coverage@2.0.5
+ nyc@14.1.1
+ @cypress/code-coverage@1.10.4
+ babel-plugin-istanbul@5.2.0

Then add the following line to cypress/support/index.js file

import '@cypress/code-coverage/support'

and the following lines to cypress/plugins/index.js file

module.exports = (on, config) => {
  on('task', require('@cypress/code-coverage/task'))
  on(
    'file:preprocessor',
    require('@cypress/code-coverage/use-browserify-istanbul'),
  )
}

Run unit tests again - the code coverage will be automatically merged and saved in several formats in the folder coverage

coverage/
  clover.xml 
  coverage-final.json
  lcov-report/
    index.html
  lcof.info

3rd party coverage services accept lcov.info and clover.xml, while for me the static page report in lcov-report/index.html is the most useful - it is a guide showing which parts of the code were hit by the tests. The counter tests were very thorough - they have covered all statements in the code, except the default parameter value (marked in yellow).

open coverage/lcov-report/index.html

Code coverage

Add one more unit test - and get the complete code coverage!

it('increments async by default value', () => {
  store.dispatch('incrementAsync')
  cy.wrap(store.state)
    .its('count')
    .should('equal', 1)
})

Bonus: run tests using GitHub Action

Testing locally is important, but testing every commit pushed to the repository is even more valuable. Recently, GitHub Actions became generally available. I looked into GH Actions in the blog post Trying GitHub Actions and came very impressed. To make using Cypress from a GH Action simpler, we have published our official custom action called cypress-io/github-action. Here is how to set up testing on each code push:

First, create file .github/workflows/main.yml and paste the following example from cypress-io/github-action README.

name: tests
on: [push]
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      # Install NPM dependencies, cache them correctly
      # and run all Cypress tests
      - name: Cypress run
        uses: cypress-io/github-action@v1

Second, push a commit to the repository.

Third - there is no third step! The tests will be running already, you can see them at bahmutov/test-vuex-with-cypress/actions. NPM installation, caching, running Cypress command - it is all done by the custom action cypress-io/github-action@v1 and the default values should handle our unit tests without any additional parameters.

Passing tests in actions

I have also added a badge to the README to know the status of the master branch. Here is the Markdown syntax:

https://github.com/<OWNER>/<REPOSITORY>/workflows/<WORKFLOW_NAME>/badge.svg?branch=<BRANCH>

![test status](https://github.com/bahmutov/test-vuex-with-cypress/workflows/tests/badge.svg?branch=master)

Conclusions

I am part of the Cypress team, so I am partial (pun intended) to this Test Runner. I like the GUI, the file watching, the time-traveling debugger, the built-in assertions and function spies and stubs. I think running front-end unit tests inside a real browser is the preferred way, and the ability to use DevTools to inspect everything the tests do gives one superpower. Take Cypress for a spin - it is not just an end-to-end test runner!

Find the full source code for this blog post at bahmutov/test-vuex-with-cypress repo. If you want to see more unit testing with Cypress examples, check out our unit testing recipes.

Discussion (0)

pic
Editor guide