Best practices in testing Vue apps
Independent consultant
Web / Mobile / VR / AR / IoT
GDE, author, engineer
Types of tests
- Static analysis
- Tests execution
Manual testing - QA
Automated tests
Static analysis
Linting
Formatting
Type Checking
Tests execution
E2E tests
Integration tests
Snapshot/visual tests
Unit Tests
E2E tests
Full system test
Browser automation
Integration tests
Test logical chunks of the system
Mock parts of the system
Caveat: hard to define
Snapshots tests
Show visual difference between elements
Unit Tests
Test small pieces of code in isolation
Unit Test
import sum from './sum'
test('returns sum of input', () => {
})
import sum from './sum'
test('returns sum of input', () => {
expect(sum(1,3)).toBe(4)
})
Why bother
- verify that code works in isolation
- catch regressions
- document your code
- enable verification processes for CI
Test problems
- Tests take time to write
- Often neglected as features come through
- Can lead to a false sense of security
- It's hard to test everything
Test strategically
- Don't test every tiny bit of code
- Test only component external contract
How to get started
vue add unit-jest
npm run test:unit
Recipe
- compile component
- mount component
- provide input
- assert output
Component
Output
Input
user action, props, store
assert
rendered output, vue events, function calls
Compile
<template>
<div></div>
</template>
<script>
export default {
props: ['visible']
}
</script>
module.exports = {
render: function () {
var _vm=this;
var _h=_vm.$createElement;
var _c=_vm._self._c||_h;
return _c('div')
}
staticRenderFns: [],
props: ['visible']
}
- Don't use template strings
So what to test?
Component contract
- presentation
- component's public API
- what would cause other components to break if it changed
Mounting
mount(Component, {
propsData: {
prop: 'some value'
},
mocks: {
$store: { state: { count: 1 } }
}
})
Mount will mount component and it's children as well.
https://vue-test-utils.vuejs.org/api/options.html
Shallow mounting
shallowMount(Component, {
propsData: {
prop: 'some value'
},
mocks: {
$store: { state: { count: 1 } }
}
})
to mount only shallow use shallowMount
Assertions
Find elements and assert their existance
wrapper.find('a').text()
wrapper.findAll('a').at(0).text()
wrapper.find('[data-test="text"]')
Assertions
Find elements and assert their existance
test('renders when clicked', () => {
// Arrange
const wrapper = mount(TestComponent)
// Act
wrapper.trigger('click')
// Assert
expect(wrapper.text()).toBe()
})
Assertions
Test callbacks by mocking them
test('calls onClose when button is clicked', () => {
const callbackFn = jest.fn()
const wrapper = mount(Component, {
propsData: {
callbackFn
}
})
wrapper.find('button').trigger('click')
expect(callbackFn).toHaveBeenCalled()
})
Snapshots
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'Vladimir Default footer'
const wrapper = mount(HelloWorld, {
propsData: { msg }
})
expect(wrapper).toMatchSnapshot()
})
})
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HelloWorld.vue renders props.msg when passed 1`] = `
<div class="hello" msg="Vladimir Default footer">
<header>Vladimir</header>
<section></section>
<footer>Default footer</footer>
</div>
`;
Snapshots
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'Vladimir Default footer'
const wrapper = mount(HelloWorld, {
propsData: { msg },
slots: {
default: '<p>some content</p>',
header: '<h1>Header</h1>',
footer: '<h2>Footer</h2>'
},
})
expect(wrapper).toMatchSnapshot()
})
})
Asserting reactive properties
it('updates text', async () => {
const wrapper = mount(Component)
wrapper.trigger('click')
await Vue.nextTick()
expect(wrapper.text()).toContain('updated')
})
Make sure to use nextTick before asserting updates of reactive properties
Emitting events
wrapper.vm.$emit('update')
wrapper.vm.$emit('updateTodo', "test todo")
Now you can test that event was emitted
expect(wrapper.emitted().updateTodo).toBeTruthy()
Testing child components
const wrapper = mount(TodoList)
wrapper.find(TodoItem).vm.$emit('remove-todo')
VueX testing
The first approach - unit test getters, mutations, and actions
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import Getters from '../../../src/components/Getters'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('Getters.vue', () => {
let getters
let store
beforeEach(() => {
getters = {
clicks: () => 2,
inputValue: () => 'input'
}
store = new Vuex.Store({
getters
})
})
it('Renders "store.getters.inputValue" in first p tag', () => {
const wrapper = shallowMount(Getters, { store, localVue })
const p = wrapper.find('p')
expect(p.text()).toBe(getters.inputValue())
})
it('Renders "store.getters.clicks" in second p tag', () => {
const wrapper = shallowMount(Getters, { store, localVue })
const p = wrapper.findAll('p').at(1)
expect(p.text()).toBe(getters.clicks().toString())
})
})
VueX testing
Second approach - creating a local store
import { createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import storeConfig from './store-config'
import { cloneDeep } from 'lodash'
test('increments "count" value when "increment" is commited', () => {
const localVue = createLocalVue()
localVue.use(Vuex)
const store = new Vuex.Store(cloneDeep(storeConfig))
expect(store.state.count).toBe(0)
store.commit('increment')
expect(store.state.count).toBe(1)
})
Vue Router
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()
shallowMount(Component, {
localVue,
router
})
Vue Router
import { shallowMount } from '@vue/test-utils'
const $route = {
path: '/some/path'
}
const wrapper = shallowMount(Component, {
mocks: {
$route
}
})
wrapper.vm.$route.path // /some/path
Stub Router links
import { mount, RouterLinkStub } from '@vue/test-utils'
const wrapper = mount(Component, {
stubs: {
RouterLink: RouterLinkStub
}
})
expect(wrapper.find(RouterLinkStub).props().to).toBe('/some/path')
Tips
- use shallow rendering
- shallowMount, mount in beforeEach
- use Vue.nextTick for reactive properties updates
- use wrapper.setData or wrapper.setProps to manipulate the component state (or simply avoid it)
- Apply global plugins and mixins through localVue.use(Plugin)
- mock injections
- stub components
TDD
Red -> Green -> Refactor
- Start by thinking about the component you gonna write
- Write basic tests first
- Write component and see tests pass
- Add more tests and see them fail
- Refactor component
- See tests pass
Component Drive Development
- Write presentational components in a stubbed environment and see them work
- Connect them to logic
Benefits
- You start thinking about components API first
- You automatically have visual guide for documentation and reference
- Easier to test
Storybook
npx -p @storybook/cli sb init --type vue
yarn storybook
stories/HelloWorld.stories.js
Storybook
yarn add @storybook/addon-storyshots --dev
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
Integration tests
Thanks
@VladimirNovick
Best practices in testing Vue apps
By Vladimir Novick
Best practices in testing Vue apps
Custom directives, Testing, Provide and Inject, Architecture patterns
- 1,252