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
.
The test should pass.
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.
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.
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)
})
})
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.
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.
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".
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.
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.
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)
})
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.
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.
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')
})
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')
})
Every call to the spy is recorded, and you can click on its record to see more details in the DevTools console.
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)
})
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')
})
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')
})
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
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.
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.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.