This book is written for Vue.js 3 and Vue Test Utils v2.

Find the Vue.js 2 version here.

Reducing Boilerplate in Tests

This article is available as a screencast on Vue.js CoursesReducing Boilerplate - 图1. Check it out hereReducing Boilerplate - 图2.

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 takes component using Vuex and VueRouter and demonstrates some patterns to help you reduce the amount of setup code for your unit tests.

The source code for the test described on this page can be found hereReducing Boilerplate - 图3.

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.

  1. <template>
  2. <div>
  3. <div id="message" v-if="message">{{ message }}</div>
  4. <div v-if="authenticated">
  5. <router-link
  6. class="new-post"
  7. to="/posts/new"
  8. >
  9. New Post
  10. </router-link>
  11. </div>
  12. <h1>Posts</h1>
  13. <div
  14. v-for="post in posts"
  15. :key="post.id"
  16. class="post"
  17. >
  18. <router-link :to="postLink(post.id)">
  19. {{ post.title }}
  20. </router-link>
  21. </div>
  22. </div>
  23. </template>
  24. <script>
  25. export default {
  26. name: 'Posts',
  27. props: {
  28. message: String,
  29. },
  30. computed: {
  31. authenticated() {
  32. return this.$store.state.authenticated
  33. },
  34. posts() {
  35. return this.$store.state.posts
  36. }
  37. },
  38. methods: {
  39. postLink(id) {
  40. return `/posts/${id}`
  41. }
  42. }
  43. }
  44. </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:

  1. // store.js
  2. export default createStore({ ... })
  3. // router.js
  4. export default createRouter({ ... })

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:

  1. // store.js
  2. export const store = createStore({ ... })
  3. export const createVuexStore = () => {
  4. return new createStore({ ... })
  5. }
  6. // router.js
  7. export default createRouter({ ... })
  8. export const createVueRouter = () => {
  9. return createRouter({ ... })
  10. }

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 { createVuexStore } 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 hereReducing Boilerplate - 图4 and the router hereReducing Boilerplate - 图5.

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:

  1. import { mount } from '@vue/test-utils'
  2. import Posts from '@/components/Posts.vue'
  3. import { createVueRouter } from '@/createRouter'
  4. import { createVuexStore } from '@/createStore'
  5. describe('Posts.vue', () => {
  6. it('renders a message if passed', () => {
  7. const store = createVuexStore()
  8. const router = createVueRouter()
  9. const message = 'New content coming soon!'
  10. const wrapper = mount(Posts, {
  11. global: {
  12. plugins: [store, router]
  13. },
  14. props: { message },
  15. })
  16. expect(wrapper.find("#message").text()).toBe('New content coming soon!')
  17. })
  18. it('renders posts', async () => {
  19. const store = createVuexStore()
  20. const router = createVueRouter()
  21. const message = 'New content coming soon!'
  22. const wrapper = mount(Posts, {
  23. global: {
  24. plugins: [store, router]
  25. },
  26. props: { message },
  27. })
  28. wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
  29. await wrapper.vm.$nextTick()
  30. expect(wrapper.findAll('.post').length).toBe(1)
  31. })
  32. })

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 createWrapper Function

The few lines of each test are the same:

  1. const store = createVuexStore(storeState)
  2. const router = createVueRouter()
  3. return mount(component, {
  4. global: {
  5. plugins: [store, router]
  6. },
  7. props: { ... }
  8. })

Let’s fix that with a function called createWrapper. It looks something like this:

  1. const createWrapper = () => {
  2. const store = createStore()
  3. const router = createRouter()
  4. return { store, router }
  5. }

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

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

  1. it('renders a message if passed', () => {
  2. const { store, router } = createWrapper()
  3. const message = 'New content coming soon!'
  4. const wrapper = mount(Posts, {
  5. global: {
  6. plugins: [store, router],
  7. },
  8. props: { message },
  9. })
  10. expect(wrapper.find("#message").text()).toBe('New content coming soon!')
  11. })

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

  1. it('renders posts', async () => {
  2. const { store, router } = createWrapper()
  3. const wrapper = mount(Posts, {
  4. global: {
  5. plugins: [store, router],
  6. }
  7. })
  8. wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
  9. await wrapper.vm.$nextTick()
  10. expect(wrapper.findAll('.post').length).toBe(1)
  11. })

Improving the createWrapper function

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 address this by updating the createWrapper function to handle mounting the component, too.

  1. const createWrapper = (component, options = {}) => {
  2. const store = createVuexStore()
  3. const router = createVueRouter()
  4. return mount(component, {
  5. global: {
  6. plugins: [store, router],
  7. },
  8. ...options
  9. })
  10. }

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

  1. it('renders a message if passed', () => {
  2. const message = 'New content coming soon!'
  3. const wrapper = createWrapper(Posts, {
  4. props: { message },
  5. })
  6. expect(wrapper.find("#message").text()).toBe('New content coming soon!')
  7. })
  8. it('renders posts', async () => {
  9. const wrapper = createWrapper(Posts)
  10. wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
  11. await wrapper.vm.$nextTick()
  12. expect(wrapper.findAll('.post').length).toBe(1)
  13. })

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 createVuexStore function, which makes it easier to set the initial state:

  1. const createVuexStore = (initialState = {}) => createStore({
  2. state() {
  3. return {
  4. authenticated: false,
  5. posts: [],
  6. ...initialState
  7. },
  8. },
  9. mutations: {
  10. // ...
  11. }
  12. })

Now we can the desired initial state to the createVuexStore function via createWrapper:

  1. const createWrapper = (component, options = {}, storeState = {}) => {
  2. const store = createVuexStore(storeState)
  3. const router = createVueRouter()
  4. return mount(component, {
  5. global: {
  6. plugins: [store, router],
  7. },
  8. ...options
  9. })
  10. }

Now our test now can be written as follows:

  1. it('renders posts', async () => {
  2. const wrapper = createWrapper(Posts, {}, {
  3. posts: [{ id: 1, title: 'Post' }]
  4. })
  5. expect(wrapper.findAll('.post').length).toBe(1)
  6. })

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 createVuexStore function to allow setting initial state for Vuex namespaced modules
  • improve createVueRouter 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 hereReducing Boilerplate - 图6. It is also available as a screencast on Vue.js CoursesReducing Boilerplate - 图7.