Module to snapshot test vuex actions with jest
I hope you are familiar with what jest, vuex and snapshot testing are.
Vuex actions are straightforward to read, and writing tests that are more complex and 10 times longer than the code they cover feels really wrong.
Actions fulfill 3 roles:
- Representation of app logic (conditions & calls of commits\dispatches)
- API for components
- Asynchronous layer for store (as mutations must be sync)
As such we unit test them to make sure that:
- When we change \ add execution path others don't get broken
- Our component API didn't change
vuex-snapshot makes this easy and declarative, even for async actions.
- ✔️ Node 6 stable or later
- ✔️
jest
and,babel-jest
installed (es6-modules imports would be used in examples, butvuex-snapshot
is also output as CommonJS)
npm install --save-dev vuex-snapshot
yarn add --dev vuex-snapshot
Say, you are testing some card game
// @/store/actions.js
export const restartGame = ({commit}) => {
commit('shuffleDeck')
commit('setScore', 0)
}
// actions.spec.js
import {snapAction} from 'vuex-snapshot'
import {restartGame} from '@/store/actions'
test('restartGame matches snapshot', () => {
expect(snapAction(restartGame)).toMatchSnapshot()
})
/*
__snapshots__/actions.spec.js
after running jest
*/
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`play restartGame matches snapshot 1`] = `
Array [
Object {
"message": "COMMIT: shuffleDeck",
},
Object {
"message": "COMMIT: setScore",
"payload": 0,
},
]
`;
NOTE: by default vuex-snapshot would not use commit & dispatch from your store, but you can pass them via mocks
// @/store/actions.js
export const openDashboard = ({commit, dispatch}) => new Promise((resolve, reject) => {
commit('setRoute', 'loading')
dispatch('load', 'dashboard')
.then(() => {
commit('setRoute', 'dashboard')
resolve()
})
.catch('reject')
})
// actions.spec.js
import {snapAction, MockPromise} from 'vuex-snapshot'
import {openDashboard} from '@/store/actions'
test('openDashboard matches success snapshot', done => {
// MockPromise can be resolved manually unlike default Promise
const dispatch = name => new MockPromise(name)
// order in which promises would be resolved
const resolutions = ['load']
snapAction(openDashboard, {dispatch}, resolutions)
.then(run => {
expect(run).toMatchSnapshot()
done()
})
})
// @/store/actions.js
export const login = ({commit, dispatch, getters}, creditals) => {
return new Promise((resolve, reject) => {
if(!getters.user.loggedIn) {
fetch('/api/login/', {
method: 'POST',
body: JSON.stringify(creditals)
})
.then(res => res.json())
.then(data => {
commit('setUser', data)
dispatch('setRoute', 'profile')
resolve()
})
.catch(reject)
} else {
resolve()
}
})
}
// actions.spec.js
import {snapAction, useMockFetch, MockPromise} from 'vuex-snapshot'
import {login} from '@/store/actions'
test('login matches success snapshot', done => {
useMockFetch()
const payload = { authCode: 1050 }
const getters = {
user: {
loggedIn: false
}
}
// this is equivalent to calling resolve(payload) inside promise cb
const resolutions = [{
name: '/api/login/',
payload: { json: () => new MockPromise('json') }
}, {
name: 'json',
payload: { name: 'someUser', id: 21 }
}]
snapAction(login, {getters, payload}, resolutions)
.then(run => {
expect(run).toMatchSnapshot()
done()
})
})
// testing error scenarios is just as easy
test('login matches network fail snapshot', done => {
useMockFetch()
const payload = { authCode: 1050 }
const getters = {
user: {
loggedIn: false
}
}
const resolutions = [{
name: '/api/login/',
type: 'reject', // resolve is default value
payload: new TypeError('Failed to fetch')
}]
snapAction(login, {getters, payload}, resolutions)
.then(run => {
/* vuex-snapshot would write that action rejected in the snapshot
so you can test rejections as well */
expect(run).toMatchSnapshot()
done()
})
})
NOTE: promises with same names would be matched to resolutions in order they were created
By using mocks object you can pass state, getters, payload(action's second argument) of any type,
as well as custom commit
and dispatch
functions.
NOTE: Make sure your getters are what they return, not how they calculate it
const action = jest.fn()
const mocks = {
payload: 0,
state: {
stateValue: 'smth'
},
getters: {
answer: 42
},
commit: console.log
dispatch: jest.fn()
}
snapAction(action, mocks)
// would call the action like
action({
state: mocks.state,
getters: mocks.getters,
commit: (name, payload) => mocks.commit(name, payload, proxies),
dispatch: (name, payload) => mocks.dispatch(name, payload, proxies),
}, mocks.payload)
Proxies is an object with commit and dispatch that were actually passed to action (not those from mocks)
Note: state and getters are being reassigned. Like they would pass
.toEqual
test, but not a.toBe
one.
import {MockPromise} from 'vuex-snapshot'
const name = 'some string'
const cb = (resolve, reject) => {}
new MockPromise(cb, name)
new MockPromise(cb) // name will be 'Promise'
new MockPromise(name) //cb will be () => {}
// some manual control
const toResolve = new MockPromise('some name')
const toReject = new MockPromise('some other name')
const payload = {type: 'any'}
toResolve.resolve(payload)
toReject.reject(payload)
console.log(toReject.name) // some other name
This class extends Promise, so Promise.all and other promise methods work perfectly for it
NOTE:
new MockPromise.then(cb)
actually creates newMockPromise
(that is default Promise behavior). As such there is a risk ofresolutions = ['Promise', 'Promise']
matching this one instead of the Promise you've meant. This is just as true forcatch
,finally
,Promise.all
andPromise.race
import {snapAction, Snapshot} from 'vuex-snapshot'
snapAction(action)
snapAction(action, mocks)
snapAction(action, resolutions)
snapAction(action, mocks, resolutions)
snapAction(action, mocks, resolutions, snapshotToWriteTo)
// where snapshotToWriteTo is instance of Snapshot class
If action returned a promise snapAction
would do the same.
That promise will resolve with an Array
of Object
s that represents action's execution.
It could be compared to snapshot, or tested manually.
If vuex-snapshot experienced internal error snapAction would reject with an Object
of following structure:
{
err, // Actual error that has been thrown
run // action's execution up to the error point
}
If action returned anything that is not a promise (including undefined
) snapAction
would
synchronously return an array mentioned above.
// all vuex-snapshot Utilities
import {
reset,
resetTimetable,
resetConfig,
useMockPromise,
useRealPromise,
useMockFetch,
useRealFetch,
} from 'vuex-snapshot'
Reset calls all other resets and useReal.
Makes sure no already created promises could be matched to resolutions.
Resets vuexSnapshot.config
to default values.
Replaces window.Promise
(same as global.Promise
) with vuexSnapshot.MockPromise
that could be named and resolved manually.
Sets window.Promise
to its original value.
Replaces window.fetch
(same as global.fetch
) with vuexSnapshot.MockPromise
that could be named and resolved manually.
Sets window.fetch
to its original value.
These fit very specific types of tests, so using
beforeEach(vuexSnapshot.reset)
is highly encouraged.
false
Instead of acting according to passed resolutions vuex-snapshot will automatically trigger resolve on each mock promise in order they were created.
false
Starts snapshot with 2 entries:
{
message: 'DATA MOCKS'
payload: {
state //value of state
getters // value of getters
}
}
{
message: 'ACTION MOCKS'
payload // passed action payload if there was one
}
// values of state, gettes and payload are not being copied
false
Allows vuexSnapshot to resolve promise returned by action.
import {snapAction, MockPromise} from 'vuex-snapshot'
test('action snapshot usnig timers', done => {
const realSetTimeout = setTimeout
window.setTimeout = (cb, time) => {
const mock = new MockPromise('Timeout')
mock.then(cb)
return realSetTimeout(mock.resolve, time)
}
// actual "test"
const action = () => new Promise(resolve => {
setTimeout(resolve, 100500)
})
snapAction(action, ['Timeout'])
.then(run => {
expect(run).toMatchSnapshot()
done()
})
.catch(err => {
console.error(err)
console.log(timetable.entries)
done()
})
window.setTimeout = realSetTimeout
})
NOTE: This is not fully accurate simulation because resolving it manually or via resolutions would cause a bit higher priority in event-loop, and resolution on timeout would be 1 tick late Because Promise.then() is not synchronous
// @/store/actions.js
export const action1 = ({commit, dispatch}) => {
commit('mutation1')
dispatch('action2')
}
export const action2 = ({commit, dispatch}) => {
commit('mutation2')
}
// actions.spec.js
import {snapAction} from 'vuex-snapshot'
import * as actions from '@/store/actions'
test('Many actions', () => {
const state = {}
const getters = {}
const dispatch = (namy, payload, {commit, dispatch}) => {
return actions[name]({state, getters, commit, dispatch}, payload)
}
expect(snapAction(actions.action1, {state, getters, dispatch})).toMatchSnapshot()
})
This should work for async actions too