Vue unit testing cheat sheet
Remark: In the time of making this cheat sheet, I had no clue about (unit) testing, so these examples might not be in terms of good/best practices.
Useful links:
- jest documentation
- expect documentation
- vue-test-utils
- globals
- expect
- jest-serializer-vue-tjw
- https://github.com/sapegin/jest-cheat-sheet
- Vue-Test-Utils Guide: Things You Might Not Know About Vue-Test-Utils
- Vue testing handbook
- Testing vuex actions
A few words before
Where is the right balance between what to test and what not to test? We can consider writing unit tests in cases like:
- when the logic behind the method is complex enough that you feel you need to test extensively to verify that it works.
- whenever it takes less time to write a unit test to verify that code works than to start up the system, log in, recreate your scenario, etc.
- when there are possible edge cases of unusually complex code that you think will probably have errors
- when a particulary complex function is receiving multiple arguments, it is a good idea to feed that function with null, undefined and unexpected data (e.g. methodNullTest, methodInvalidValueTest, methodValidValueTest)
- when there are cases that require complex steps for reproduction and can be easily forgotten
It's also important to say that we have to tests are inputs and outputs, and NOT the logic between them. Meaning, for example, if we are testing a component that will give us a random number as a result, where we will specify the minimum and maximum number, our inputs will be the lowest number and the highest number (the range) and the output will be our random number. We are not interested in the steps how that result is calculated, just the inputs and outpust. That gives us the flexibility to change or optimize the logic, but the tests should not fail if we do that.
Also, we shouldn't test the framework we are using not the 3rd party libraries. We have to assume they are tested.
Try to keep test methods short and sweet and add them to the build.
Setuping jest
To avoid this chapter, if you are just starting a project or learning, just scafold a new vue project with vue create
and while manually selecting features needed for your project, pick unit testing
and Jest
as your testing framework of choice.
Follow these steps for the most basic jest setup:
-
npm i --save-dev jest
-
add
"unit": "jest"
to your package.json file{ "scripts": { "unit": "jest", }, }
-
create
Component.spec.js
file in the component folder -
add
jest: true
to your.eslintrc.js
file (so that eslint knows the jest keywords like describe, expect etc.){ env: { browser: true, jest: true }, }
-
Write tests in the
Component.spec.js
file and run it withnpm run unit
Things you'll almost always use
- (1) use
mount
to mount the component and store it inwrapper
- (2) access
data
in a component - (3) change a variable in
data
in a component - (4) find an element in a component and check it's properties
- (5) trigger events like
click
on an element - (6) check if a component has an html element
- (7) manually pass props to a component
Frequent terminology
- Shallow Rendering - a technique that assures your component is rendering without children. This is useful for:
- Testing only the component you want to test (that's what Unit Test stands for)
- Avoid side effects that children components can have, such as making HTTP calls, calling store actions...
Sanity test
Sanity tests will obviously always have to pass. We are writing them to see that if they somehow fail, we probably didn't set up something right.
describe('Component.vue', () => {
test('sanity test', () => {
expect(true).toBe(true) // will obviously always pass
})
})
Access Vue component
// component access
import { mount } from '@vue/test-utils'
import Modal from '../Modal.vue'
const wrapper = mount(Modal)
wrapper.vm // access to the component instance
wrapper.element // access to the component DOM node
Test Vue components
// Import Vue and the component being tested
import Vue from 'vue'
import MyComponent from 'path/to/MyComponent.vue'
describe('MyComponent', () => {
// Inspect the raw component options
it('has a created hook', () => {
expect(typeof MyComponent.created).toBe('function')
})
// access the component data
it('check init state of "message" from data', () => {
const vm = new Vue(MyComponent).$mount()
expect(vm.message).toBe('bla')
})
// usage of helper functions
it('hides the body initially', () => {
h.domHasNot('body')
})
it('shows body when clicking title', () => {
h.click('title')
h.domHas('.body')
})
it('renders the correct title and subtitle after clicking button', () => {
h.click('.my-button')
h.see('My title')
h.see('My subtitle')
})
it('adds expanded class to expanded post', () => {
h.click('.post')
expect(wrapper.classes()).toContain('expanded')
})
it('show comments when expanded', () => {
h.click('.post')
h.domHas('.comment')
})
})
Test default and passed props
// we'll create a helper factory function to create a message component, give some properties
const createCmp = propsData => mount(KaModal, { propsData });
it("has a message property", () => {
cmp = createCmp({ message: "hey" });
expect(cmp.props().message).toBe("hey");
});
it("has no cat property", () => {
cmp = createCmp({ cat: "hey" });
expect(cmp.props().cat).toBeUndefined();
});
// test default value of author prop
it("Paco is the default author", () => {
cmp = createCmp({ message: "hey" });
expect(cmp.props().author).toBe("Paco");
});
// slightly different approach
// pass props to child with 'propsData'
it('renders correctly with different props', () => {
const Constructor = Vue.extend(MyComponent)
const vm = new Constructor({ propsData: {msg: 'Hello'} }).$mount()
expect(vm.$el.textContent).toBe('Hello')
})
// prop type
it("hasClose is bool type", () => {
const message = createCmp({title: "hey"});
expect(message.vm.$options.props.hasClose.type).toBe(Boolean);
})
// prop required
it("hasClose is not required", () => {
const message = createCmp({title: "hey"});
expect(message.vm.$options.props.hasClose.required).toBeFalsy();
})
// custom events
it("Calls handleMessageClick when @message-click happens", () => {
const stub = jest.fn();
cmp.setMethods({ handleMessageClick: stub });
const el = cmp.find(Message).vm.$emit("message-clicked", "cat");
expect(stub).toBeCalledWith("cat");
});
Test visibility of component with passed prop
test('does not render when not passed visible prop', () => {
const wrapper = mount(Modal)
expect(wrapper.isEmpty()).toBe(true)
})
test('render when visibility prop is true', () => {
const wrapper = mount(Modal, {
propsData: {
visible: true
}
})
expect(wrapper.isEmpty()).toBe(false)
})
test('call close() method when X is clicked', () => {
const close = jest.fn()
const wrapper = mount(Modal, {
propsData: {
visible: true,
close
}
})
wrapper.find('button').trigger('click')
expect(close).toHaveBeenCalled()
})
Test computed properties
it("returns the string in normal order", () => {
cmp.setData({ inputValue: "Yoo" });
expect(cmp.vm.reversedInput).toBe("Yoo");
});
Vuex actions
Before testing anything from the vuex store, we need to "mock" (make dummy / hardcode) the store values that we want to test. In the case beneath, we simulated the result of the getComments async action to give us 2 comments. Read more https://lmiller1990.github.io/vue-testing-handbook/vuex-actions.html#creating-the-action
import Vuex from 'vuex'
import { shallow, createLocalVue } from '@vue/test-utils'
import BlogComments from '@/components/blog/BlogComments'
import TestHelpers from 'test/test-helpers'
import Loader from '@/components/Loader'
import flushPromises from 'flush-promises'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('BlogComments', () => {
let wrapper
let store
// eslint-disable-next-line
let h
let actions
beforeEach(() => {
actions = {
getComments: jest.fn(() => {
return new Promise(resolve => {
process.nextTick(() => {
resolve([{ title: 'title 1' }, { title: 'title 2' }])
})
})
})
}
store = new Vuex.Store({
modules: {
blog: {
namespaced: true,
actions
}
}
})
wrapper = shallow(BlogComments, {
localVue,
store,
propsData: {
id: 1
},
stubs: {
Loader
},
mocks: {
$texts: {
noComments: 'No comments'
}
}
})
h = new TestHelpers(wrapper, expect)
})
it('renders without errors', () => {
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('calls action to get comments on mount', () => {
expect(actions.getComments).toHaveBeenCalled()
})
it('shows loader initially, and hides it when comments have been loaded', async () => {
h.domHas(Loader)
await flushPromises()
h.domHasNot(Loader)
})
it('has list of comments', async () => {
await flushPromises()
const comments = wrapper.findAll('.comment')
expect(comments.length).toBe(2)
})
it('shows message if there are no comments', async () => {
await flushPromises()
wrapper.setData({
comments: []
})
h.domHas('.no-comments')
h.see('No comments')
})
})
Vuex mutations and getters
Don't forget to correctly export mutations and getters so they can be accessible in the tests
import { getters, mutations } from '@/store/modules/blog'
describe('blog store module', () => {
let state
beforeEach(() => {
state = {
blogPosts: []
}
})
describe('getters', () => {
it('hasBlogPosts logic works', () => {
expect(getters.hasBlogPosts(state)).toBe(false)
state.blogPosts = [{}, {}]
expect(getters.hasBlogPosts(state)).toBe(true)
})
it('numberOfPosts returns correct count', () => {
expect(getters.numberOfPosts(state)).toBe(0)
state.blogPosts = [{}, {}]
expect(getters.numberOfPosts(state)).toBe(2)
})
})
describe('mutations', () => {
it('adds blog posts correctly', () => {
mutations.saveBlogPosts(state, [{ title: 'New post' }])
expect(state.blogPosts).toEqual([{ title: 'New post' }])
})
})
})
Snapshot test
From the official jest documentation:
Snapshot tests are a very useful tool whenever you want to make sure your UI does not change unexpectedly. A typical snapshot test case for a mobile app renders a UI component, takes a screenshot, then compares it to a reference image stored alongside the test. The test will fail if the two images do not match: either the change is unexpected, or the screenshot needs to be updated to the new version of the UI component.
You can use this plugin to improve and better control the formatting of your snapshots:
test('Modal renders correctly when visible: true', () => {
const wrapper = mount(Modal, {
propsData: {
visible: true
}
});
// Will store a snapshot of the entire component
expect(wrapper)
.toMatchSnapshot();
});
test('Advanced form is shown after clicking "show more"', async () => {
const wrapper = mount(GenericForm);
// Target a specific element and similuate interacting with it
const showMore = wrapper.find('[data-test="showMore"]');
showMore.trigger('click');
// Wait for the trigger event to be handled
await wrapper.vm.$nextTick();
// Will store a snapshot of a portion of the component containing
// the element that has a matching data-test attribute.
expect(wrapper.find('[data-test="advancedForm"]'))
.toMatchSnapshot();
});
Testing helper functions
import { sort } from './helpers'
describe('Helper functions', () => {
test('it sorts the array', () => {
const arr = [3,1,5,4,2]
const res = sort(arr, 'asc')
expect(res).toEqual(arr.sort((a,b) => a-b))
})
})
helpers
Usefulclass TestHelpers {
constructor(wrapper, expect) {
this.wrapper = wrapper
this.expect = expect
}
see(text, selector) {
let wrap = selector ? this.wrapper.find(selector) : this.wrapper
this.expect(wrap.html()).toContain(text)
}
doNotSee(text) {
this.expect(this.wrapper.html()).not.toContain(text)
}
type(text, input) {
let node = this.find(input)
node.element.value = text
node.trigger('input')
}
click(selector) {
this.wrapper.find(selector).trigger('click')
}
inputValueIs(text, selector) {
this.expect(this.find(selector).element.value).toBe(text)
}
inputValueIsNot(text, selector) {
this.expect(this.find(selector).element.value).not.toBe(text)
}
domHas(selector) {
this.expect(this.wrapper.contains(selector)).toBe(true)
}
domHasNot(selector) {
this.expect(this.wrapper.contains(selector)).toBe(false)
}
domHasLength(selector, length) {
this.expect(this.wrapper.findAll(selector).length).toBe(length)
}
isVisible(selector) {
this.expect(this.find(selector).element.style.display).not.toEqual('none')
}
isHidden(selector) {
this.expect(this.find(selector).element.style.display).toEqual('none')
}
find(selector) {
return this.wrapper.find(selector)
}
hasAttribute(selector, attribute) {
return this.expect(this.find(selector).attributes()[attribute]).toBeTruthy()
}
}
export default TestHelpers
// how to use helpers
import TestHelpers from 'test/test-helpers'
let h = new TestHelpers(wrapper, expect)
h.domHas('.loader')