Skip to content

v-trof/vuex-snapshot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

64 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vuex-snapshot • codecov Build Status

Module to snapshot test vuex actions with jest

Table of contents

Why use snapshot tests for actions?

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:

  1. Representation of app logic (conditions & calls of commits\dispatches)
  2. API for components
  3. Asynchronous layer for store (as mutations must be sync)

As such we unit test them to make sure that:

  1. When we change \ add execution path others don't get broken
  2. Our component API didn't change

vuex-snapshot makes this easy and declarative, even for async actions.

Getting started

Prerequisites

  • ✔️ Node 6 stable or later
  • ✔️ jest and, babel-jest installed (es6-modules imports would be used in examples, but vuex-snapshot is also output as CommonJS)

Installation

via npm

npm install --save-dev vuex-snapshot

via yarn

yarn add --dev vuex-snapshot

Basic example

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

Usage

Testing async actions

// @/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()
    })
})

Testing async actions [2]

// @/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

mocks

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

Example

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.

MockPromises

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 new MockPromise (that is default Promise behavior). As such there is a risk of resolutions = ['Promise', 'Promise'] matching this one instead of the Promise you've meant. This is just as true for catch, finally, Promise.all and Promise.race

snapAction overloads

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 Objects 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.

Utilities

// all vuex-snapshot Utilities
import {
  reset,
  
  resetTimetable,
  resetConfig,

  useMockPromise,
  useRealPromise,

  useMockFetch,
  useRealFetch,
} from 'vuex-snapshot'

reset

Reset calls all other resets and useReal.

resetTimetable

Makes sure no already created promises could be matched to resolutions.

resetConfig

Resets vuexSnapshot.config to default values.

useMockPromise

Replaces window.Promise (same as global.Promise) with vuexSnapshot.MockPromise that could be named and resolved manually.

useRealPromise

Sets window.Promise to its original value.

useMockFetch

Replaces window.fetch (same as global.fetch) with vuexSnapshot.MockPromise that could be named and resolved manually.

useRealFetch

Sets window.fetch to its original value.

Config

These fit very specific types of tests, so using beforeEach(vuexSnapshot.reset) is highly encouraged.

vuexSnapshot.config.autoResolve

Default

false

Description

Instead of acting according to passed resolutions vuex-snapshot will automatically trigger resolve on each mock promise in order they were created.

vuexSnapshot.config.snapEnv

Default

false

Description

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

vuexSnapshot.config.allowManualActionResolution

Default

false

Description

Allows vuexSnapshot to resolve promise returned by action.

Tips

Mocking timers for vuex-snapshot resolutions

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

Deep testing (execute called actions)

// @/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

Releases

No releases published

Packages

No packages published