Skip to content

Instantly share code, notes, and snippets.

@nicodevs
Created August 24, 2020 19:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nicodevs/f8b92ba824ec11559dfc68ae00b07121 to your computer and use it in GitHub Desktop.
Save nicodevs/f8b92ba824ec11559dfc68ae00b07121 to your computer and use it in GitHub Desktop.
Tips for advanced Vue testing

Tips for advanced testing

Consider the following Vue single file component:

<template>
  <form
    :class="theme"
    @submit.prevent="submit">
    <input
      v-model="credentials.email"
      type="email">
    <input
      v-model="credentials.password"
      type="password">
    <button
      :disabled="loading">
      Login
    </button>
    <p
      v-if="error">
      {{ error }}
    </p>
  </form>
</template>

<script>
export default {
  props: {
    theme: {
      type: String,
      default: 'light'
    }
  },
  data () {
    return {
      error: null,
      loading: false,
      credentials: {
        email: null,
        password: null
      }
    }
  },
  methods: {
    async submit () {
      this.loading = true
      this.error = null
      try {
        await this.$store.dispatch('user/login', this.credentials)
        this.$router.push('dashboard')
      } catch (e) {
        this.error = 'Sorry, please try again'
      }
      this.loading = false
    }
  }
}
</script>

It is a pretty simple login form, but there are a few gotchas to fully test its functionality. Let's check them out!

Vue Test Utils

Using Vue Test Utils and Jest we can setup a unit test like this:

import { mount } from '@vue/test-utils'
import Demo from '~/components/Demo.vue'

describe('Demo', () => {
  const wrapper = mount(Demo)

  it('renders the form', () => {
    const form = wrapper.find('form')
    expect(form.exists()).toBe(true)
  })
})

This will be the backbone of all the upcoming tests. We can run yarn run jest Demo.test --verbose to see the result of that first test:

 PASS  tests/unit/Demo.test.js
  Demo
    ✓ renders the form (6 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

Jest DOM and asynchronous behavior

Let's say we need to check if the form is visible and has the correct CSS applied. An perfect tool for the job is Jest DOM, a library that let us add custom Jest matchers that you can use to extend the basic functions of Jest.

We can install the library running yarn add --dev @testing-library/jest-dom. Then we import the helpers we need to our test and extend Jest:

import { toHaveClass, toBeVisible } from '@testing-library/jest-dom/matchers'
expect.extend({ toHaveClass, toBeVisible })

The component should apply a CSS class equivalent to its theme prop, which default value is light. So we can improve our test to check that:

it('renders the form', () => {
  const form = wrapper.find('form')
  expect(form.exists()).toBe(true)
  expect(form.element).toBeVisible()
  expect(form.element).toHaveClass('light')
})

Note that we make the new assertions using form.element, because the assertions should be made using the DOM element itself, not the wrapper returned by the find function.

We can extend the test to see if changing the theme prop changes the CSS class the form has applied:

wrapper.setProps({ theme: 'dark' })
expect(form.element).toHaveClass('dark')

However, that asserting will fail:

FAIL  tests/unit/Demo.test.js
 Demo
   ✕ renders the form (31 ms)

 ● Demo › renders the form

   expect(element).toHaveClass("dark")

   Expected the element to have class:
     dark
   Received:
     light

Why? Because by default, Vue batches updates to run asynchronously (on the next "tick"). So we have to await the next tick before we make a new assertion. So, our complete test should look like this:

it('renders the form', async () => {
  const form = wrapper.find('form')
  expect(form.exists()).toBe(true)
  expect(form.element).toBeVisible()
  expect(form.element).toHaveClass('light')

  wrapper.setProps({ theme: 'dark' })
  await wrapper.vm.$nextTick()
  expect(form.element).toHaveClass('dark')
})

Note that now the test is an async function, so we can await the $nextTick. And the test passes!

PASS  tests/unit/Demo.test.js
 Demo
   ✓ renders the form (31 ms)

Mocking Vuex and async actions

Let's say that we want to test that the form submission dispatches the correct action on Vuex. Simply put, we want to test this line:

this.$store.dispatch('user/login', this.form)

To achieve that we can mock the store and the dispatch method, and use the function Jest provides to check if the method has been called, toHaveBeenCalled. We can even test if the method has received the correct parameters using toHaveBeenCalledWith.

So, we can mock the store and the dispatch method manually, but it's better to use a tool like Posva's Vuex Mock Store. This library will let us mock all the parts of a Vuex store (state, getters, actions and mutations) in a very simple way.

First, install the package doing yarn add -D vuex-mock-store. Then, import it to our Jest test like this:

import { Store } from 'vuex-mock-store'

Creating a mocked store is very simple:

const store = new Store()

Now we just need to pass the mocked store as an option when creating the component wrapper, so every reference to $store is made to our mock instead of the real Vuex store:

const wrapper = mount(Demo, {
  mocks: {
    $store: store
  }
})

That will allow us to use the mock inside our tests and check if it the dispatch method has been called with the right parameters. This would be the full example:

import { Store } from 'vuex-mock-store'
import { mount } from '@vue/test-utils'
import Demo from '~/components/Demo.vue'

const store = new Store()

describe('Demo', () => {
  const wrapper = mount(Demo, {
    mocks: {
      $store: store
    }
  })

  it('renders the form', () => {
    expect(wrapper.find('form').exists()).toBe(true)
  })

  it('submits the form', () => {
    const credentials = { email: 'foo@example.com', password: '123456' }
    wrapper.vm.credentials = credentials
    wrapper.find('form').trigger('submit')

    expect(store.dispatch).toHaveBeenCalledWith('user/login', credentials)
  })
})

What is this test doing?

First, we complete the credentials object with an email and password. We can either complete the inputs or, like we did here, assign the object to the component data. Remember to use wrapper.vm to access the instance of the component. Then, we trigger a submit event on the form.

And then we make our assertion: we expect the dispatch method to have been called with two parameters, beign 'user/login' the first one and credentials the second one.

expect(store.dispatch).toHaveBeenCalledWith('user/login', credentials)

It's very important to know that the test won't hit the real Vuex store of our application, but a mocked version. So no real Vuex actions will be executed (no AJAX calls or commits). You can test the results of the actions on a different suite, this test only checks that the component communicates with Vuex as expected.

If we run the test, we'll see that the test passes:

 PASS  tests/unit/Demo.test.js
  Demo
    ✓ renders the form (7 ms)
    ✓ submits the form (5 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

But, what if we want to test the next lines of code? After hitting Vuex, the component redirects to another page:

await this.$store.dispatch('user/login', this.credentials)
this.$router.push('dashboard')

So we'll need to mock the push method of the Vue router. That's pretty simple, we can create a new mock object containing only the method we need to test:

const router = {
  push: jest.fn()
}

And tell the wrapper that the instance of $router will be replaced by our mock:

const wrapper = mount(Demo, {
  mocks: {
    $store: store,
    $router: router
  }
})

What is jest.fn()? Is a simple mocked function Jest provides. You don't need to import anything special to use it.

So we can go ahead and extend our test:

it('submits the form', () => {
  const credentials = { email: 'foo@example.com', password: '123456' }
  wrapper.vm.credentials = credentials
  wrapper.find('form').trigger('submit')
  expect(store.dispatch).toHaveBeenCalledWith('user/login', credentials)
  expect(router.push).toHaveBeenCalledWith('dashboard')
})

Seems good! But when you run the test you'll find an error:

 FAIL  tests/unit/Demo.test.js
  Demo
    ✓ renders the form (6 ms)
    ✕ submits the form (6 ms)

  ● Demo › submits the form

    expect(jest.fn()).toHaveBeenCalledWith(...expected)

    Expected: "dashboard"

    Number of calls: 0

Jest is using our mock (we can see that is making a reference to jest.fn), but it says that it has not been called (Number of calls: 0). Why is that?

Well, let's check the code of our component:

async submit () {
  this.loading = true
  this.resetError()
  try {
    await this.$store.dispatch('user/login', this.credentials)
    this.$router.push('dashboard')
  } catch (e) {
    this.setError()
  }
  this.loading = false
}

As you can see, the method is async and the call to push is after a promise. If the promise is not resolved (or rejected), the next line is never executed, and then the number of calls to our mocked push method will be zero as the error says.

Let's fix that!

Testing async methods

To flush all pending resolved promise handlers we can use the Flush Promises library. Install it using yarn add flush-promises and import it to your test like this:

import flushPromises from 'flush-promises'

In our test we can flush the promise of our awaited promise, the dispatch call:

it('submits the form', async () => {
  const credentials = { email: 'foo@example.com', password: '123456' }
  wrapper.vm.credentials = credentials
  wrapper.find('form').trigger('submit')
  expect(store.dispatch).toHaveBeenCalledWith('user/login', credentials)
  await flushPromises()
  expect(router.push).toHaveBeenCalledWith('dashboard')
})

Note that now our test itself is an async function, because it has to await the flushPromise.

If we run the test again, we'll see it passing:

 PASS  tests/unit/Demo.test.js
  Demo
    ✓ renders the form (7 ms)
    ✓ submits the form (5 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

That cover's the happy path: a valid set of credentials is dispatched to our 'user/login' action and the user is redirected to the Dashboard page. But, how can we test the behaviour of our component when the process fails?

Testing async error handling

Let's say we need to test this lines:

} catch (e) {
  this.error = 'Sorry, please try again'
}

By default, the dispatch method mocked by Vuex Mock Store returns undefined. In this case we need to mock a call that fails, a promise that got rejected (in the real Vuex store this could be an AJAX call with a failed status code like 404 or 500). Fortunately, it's easy to mock that failure:

store.dispatch.mockReturnValue(Promise.reject(new Error()))

With that line we say that the value returned by the store.dispatch function should be a rejected promise. You can even pass a custom error, if needed. The execution will fall on the catch part of our try, and set the error data that we need to check:

try {
  // The mocked store will make this promise fail:
  await this.$store.dispatch('user/login', this.credentials)
  this.$router.push('dashboard')
} catch (e) {
  // ... and the execute this code:
  this.error = 'Sorry, please try again'
}

To assert the error value we can do:

expect(wrapper.vm.error).toBe('Sorry, please try again')

So the full test will look like this:

it('shows an error if the form submission fails', async () => {
  store.dispatch.mockReturnValue(Promise.reject(new Error()))
  wrapper.vm.credentials = { email: 'foo@example.com', password: '123456' }
  wrapper.find('form').trigger('submit')
  await flushPromises()
  expect(wrapper.vm.error).toBe('Sorry, please try again')
})

Hope this examples help you cover more scenarios in your test suite. Happy testing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment