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