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!
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
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)
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: '[email protected]', 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: '[email protected]', 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!
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 await
ed promise, the dispatch
call:
it('submits the form', async () => {
const credentials = { email: '[email protected]', 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?
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: '[email protected]', 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!