🔬 My cheat sheet for testing vue components with jest and vue test utils
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.
🙏 Many examples in here are taken from the repos, videos and tutorials from MikaelEdebro, Edd Yerburgh, Alex Jover and others.
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
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.
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
- (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
- 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 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
})
})
// 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
// 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')
})
})
// 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('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()
})
it("returns the string in normal order", () => {
cmp.setData({ inputValue: "Yoo" });
expect(cmp.vm.reversedInput).toBe("Yoo");
});
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')
})
})
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' }])
})
})
})
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();
});
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))
})
})
Useful helpers
class 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')