Reducing Duplication in Unit Tests


This article is available as a screencast!

Reducing boilerplate in your unit tests can improve readability. In this article, we refactor the unit tests for a component using Vuex and VueRouter, and extract a useful utility function that can be used throughout the entire codebase.

It is often ideal to start each unit test with a fresh copy of a component. Furthermore, as your apps get larger and more complex, chances are you have a some components with many different props, and possibly a number of third party libraries such as Vuetify, VueRouter and Vuex installed. This can cause your tests to have lots of boilerplate code - that is, code that is not directly related to the test.

This article is available as a screencast!

The source code for the test described on this page can be found here.

The Posts Component

This is the component we will be testing. It shows a message prop, if one is received. It shows a New Post button if the user is authenticated and some posts. Both of the authenticated and posts objects come from the Vuex store. Finally, it renders are router-link component, showing a link to a post.

<template>
<div>
<div id="message" v-if="message">{{ message }}</div>

<div v-if="authenticated">
<router-link
class="new-post"
to="/posts/new"
>
New Post
</router-link>
</div>

<h1>Posts</h1>
<div
v-for="post in posts"
:key="post.id"
class="post"
>
<router-link :to="postLink(post.id)">
{{ post.title }}
</router-link>
</div>
</div>
</template>

<script>
export default {
name: 'Posts',
props: {
message: String,
},

computed: {
authenticated() {
return this.$store.state.authenticated
},

posts() {
return this.$store.state.posts
}
},

methods: {
postLink(id) {
return `/posts/${id}`
}
}
}
</script>

We want to test:

  • is the message rendered when a prop is received?
  • are the posts correctly rendered?
  • is the New Post button shown when authenticated is true, hidden when false?

Ideally, the tests should be as concise as possible.

Vuex/VueRouter Factory Functions

One good step you can take to making apps more testable is export factory functions for Vuex and VueRouter. Often, you will see something like:

// store.js

export default new Vuex.Store({ ... })

// router.js
export default new VueRouter({ ... })

This is fine for a regular application, but not ideal for testing. If you do this, every time you use the store or router in a test, it will be shared across every other test that also imports it. Ideally, every component should get a fresh copy of the store and router.

One easy way to work around this is by exporting a factory function - a function that returns a new instance of an object. For example:

// store.js
export const store = new Vuex.Store({ ... })
export const createStore = () => {
return new Vuex.Store({ ... })
}

// router.js
export default new VueRouter({ ... })
export const createRouter = () => {
return new Vuex.Router({ ... })
}

Now your main app can do import { store } from './store.js, and your tests can get a new copy of the store each time by doing import { createStore } from './store.js, then creating and instance with const store = createStore(). The same goes for the router. This is what I am doing in the Posts.vue example - the store code is found here and the router here.

The Tests (before refactor)

Now we know what Posts.vue and the store and router look like, we can understand what the tests are doing:

import Vuex from 'vuex'
import VueRouter from 'vue-router'
import { mount, createLocalVue } from '@vue/test-utils'

import Posts from '@/components/Posts.vue'
import { createRouter } from '@/createRouter'
import { createStore } from '@/createStore'

describe('Posts.vue', () => {
it('renders a message if passed', () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()
const message = 'New content coming soon!'
const wrapper = mount(Posts, {
propsData: { message },
store, router,
})

expect(wrapper.find("#message").text()).toBe('New content coming soon!')
})

it('renders posts', async () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()
const message = 'New content coming soon!'

const wrapper = mount(Posts, {
propsData: { message },
store, router,
})

wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
await wrapper.vm.$nextTick()

expect(wrapper.findAll('.post').length).toBe(1)
})
})

This does not fully tests all the conditions; it’s a minimal example, and enough to get us started. Notice the duplication and repetition - let’s get rid of that.

A Custom createTestVue Function

The first five line of each test are the same:

const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()

Let’s fix that. As not to be confused with Vue Test Utils’ createLocalVue function, I like to call my function createTestVue. It looks something like this:

const createTestVue = () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()
return { store, router, localVue }
}

Now we have encapsulated all the logic in a single function. We return the store, router and localVue since we need to pass them to the mount function.

If we refactor the first test using createTestVue, it looks like this:

it('renders a message if passed', () => {
const { localVue, store, router } = createTestVue()
const message = 'New content coming soon!'
const wrapper = mount(Posts, {
propsData: { message },
store,
router,
localVue
})

expect(wrapper.find("#message").text()).toBe('New content coming soon!')
})

Quite a bit more concise. Let’s refactor second test, which makes use of the of Vuex store.

it('renders posts', async () => {
const { localVue, store, router } = createTestVue()
const wrapper = mount(Posts, {
store,
router,
})

wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
await wrapper.vm.$nextTick()

expect(wrapper.findAll('.post').length).toBe(1)
})

Defining a createWrapper method

While the above code is definitely an improvement, comparing this and the previous test, we can notice that about half of the code is still duplicated. Let’s create a new method, createWrapper, to address this.

const createWrapper = (component, options = {}) => {
const { localVue, store, router } = createTestVue()
return mount(component, {
store,
router,
localVue,
...options
})
}

Now we can just called createWrapper and have a fresh copy of the component, ready for testing. Our tests are very concise now.

it('renders a message if passed', () => {
const message = 'New content coming soon!'
const wrapper = createWrapper(Posts, {
propsData: { message },
})

expect(wrapper.find("#message").text()).toBe('New content coming soon!')
})

it('renders posts', async () => {
const wrapper = createWrapper(Posts)

wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
await wrapper.vm.$nextTick()

expect(wrapper.findAll('.post').length).toBe(1)
})

Setting the Initial Vuex State

The last improvement we can make is to how we populate the Vuex store. In a real application, you store is likely to be complex, and having to commit and dispatch many different mutations and actions to get your component into the state you want to test is not ideal. We can make a small change to our createStore function, which makes it easier to set the initial state:

const createStore = (initialState = {}) => new Vuex.Store({
state: {
authenticated: false,
posts: [],
...initialState
},
mutations: {
// ...
}
})

Now we can the desired initial state to the createStore function. We can do a quick refactor, merging createTestVue and createWrapper:

const createWrapper = (component, options = {}, storeState = {}) => {
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)
const store = createStore(storeState)
const router = createRouter()

return mount(component, {
store,
router,
localVue,
...options
})
}

Now our test now can be written as follows:

it('renders posts', async () => {
const wrapper = createWrapper(Posts, {}, {
posts: [{ id: 1, title: 'Post' }]
})

expect(wrapper.findAll('.post').length).toBe(1)
})

This is a big improvement! We went from a test where roughly half the code was boilerplate, and not actually related to the assertion, to two lines; one to prepare the component for testing, and one for the assertion.

Another bonus of this refactor is we have a flexible createWrapper function, which we can use for all our tests.

Improvements

There are some other potential improvements:

  • update the createStore function to allow setting initial state for Vuex namespaced modules
  • improve createRouter to set a specific route
  • allow the user to pass a shallow or mount argument to createWrapper

Conclusion

This guide discussed:

  • using factory functions to get a new instance of an object
  • reducing boilerplate and duplication by extract common behavior

The source code for the test described on this page can be found here. This article is also available as a screencast here!


Get occasional emails about new content and blog posts.
Absolutely no unsolicted spam. Unsubscribe anytime.
Thanks for registering!