A Crash Course
Let's jump right into it! Let's learn Vue Test Utils (VTU) by building a simple Todo app and writing tests as we go. This guide will cover how to:
- Mount components
- Find elements
- Fill out forms
- Trigger events
Getting Started
We will start off with a simple TodoApp
component with a single todo:
<template>
<div></div>
</template>
<script>
export default {
name: 'TodoApp',
data() {
return {
todos: [
{
id: 1,
text: 'Learn Vue.js 3',
completed: false
}
]
}
}
}
</script>
The first test - a todo is rendered
The first test we will write verifies a todo is rendered. Let's see the test first, then discuss each part:
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('renders a todo', () => {
const wrapper = mount(TodoApp)
const todo = wrapper.get('[data-test="todo"]')
expect(todo.text()).toBe('Learn Vue.js 3')
})
We start off by importing mount
- this is the main way to render a component in VTU. You declare a test by using the test
function with a short description of the test. The test
and expect
functions are globally available in most test runners (this example uses Jest). If test
and expect
look confusing, the Jest documentation has a more simple example of how to use them and how they work.
Next, we call mount
and pass the component as the first argument - this is something almost every test you write will do. By convention, we assign the result to a variable called wrapper
, since mount
provides a simple "wrapper" around the app with some convenient methods for testing.
Finally, we use another global function common to many tests runner - Jest included - expect
. The idea is we are asserting, or expecting, the actual output to match what we think it should be. In this case, we are finding an element with the selector data-test="todo"
- in the DOM, this will look like <div data-test="todo">...</div>
. We then call the text
method to get the content, which we expect to be 'Learn Vue.js 3'
.
Using
data-test
selectors is not required, but it can make your tests less brittle. Classes and ids tend to change or move around as an application grows - by usingdata-test
, it's clear to other developers which elements are used in tests, and should not be changed.
Making the test pass
If we run this test now, it fails with the following error message: Unable to get [data-test="todo"]
. That's because we aren't rendering any todo item, so the get()
call is failing to return a wrapper (remember, VTU wraps all components, and DOM elements, in a "wrapper" with some convenient methods). Let's update <template>
in TodoApp.vue
to render the todos
array:
<template>
<div>
<div v-for="todo in todos" :key="todo.id" data-test="todo">
{{ todo.text }}
</div>
</div>
</template>
With this change, the test is passing. Congratulations! You wrote your first component test.
Adding a new todo
The next feature we will be adding is for the user to be able to create a new todo. To do so, we need a form with an input for the user to type some text. When the user submits the form, we expect the new todo to be rendered. Let's take a look at the test:
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('creates a todo', () => {
const wrapper = mount(TodoApp)
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(1)
wrapper.get('[data-test="new-todo"]').setValue('New todo')
wrapper.get('[data-test="form"]').trigger('submit')
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
As usual, we start of by using mount
to render the element. We are also asserting that only 1 todo is rendered - this makes it clear that we are adding an additional todo, as the final line of the test suggests.
To update the <input>
, we use setValue
- this allows us to set the input's value.
After updating the <input>
, we use the trigger
method to simulate the user submitting the form. Finally, we assert the number of todo items has increased from 1 to 2.
If we run this test, it will obviously fail. Let's update TodoApp.vue
to have the <form>
and <input>
elements and make the test pass:
<template>
<div>
<div v-for="todo in todos" :key="todo.id" data-test="todo">
{{ todo.text }}
</div>
<form data-test="form" @submit.prevent="createTodo">
<input data-test="new-todo" v-model="newTodo" />
</form>
</div>
</template>
<script>
export default {
name: 'TodoApp',
data() {
return {
newTodo: '',
todos: [
{
id: 1,
text: 'Learn Vue.js 3',
completed: false
}
]
}
},
methods: {
createTodo() {
this.todos.push({
id: 2,
text: this.newTodo,
completed: false
})
}
}
}
</script>
We are using v-model
to bind to the <input>
and @submit
to listen for the form submission. When the form is submitted, createTodo
is called and inserts a new todo into the todos
array.
While this looks good, running the test shows an error:
expect(received).toHaveLength(expected)
Expected length: 2
Received length: 1
Received array: [{"element": <div data-test="todo">Learn Vue.js 3</div>}]
The number of todos has not increased. The problem is that Jest executes tests in a synchronous manner, ending the test as soon as the final function is called. Vue, however, updates the DOM asynchronously. We need to mark the test async
, and call await
on any methods that might cause the DOM to change. trigger
is one such methods, and so is setValue
- we can simply prepend await
and the test should work as expected:
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('creates a todo', async () => {
const wrapper = mount(TodoApp)
await wrapper.get('[data-test="new-todo"]').setValue('New todo')
await wrapper.get('[data-test="form"]').trigger('submit')
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
Now the test is finally passing!
Completing a todo
Now that we can create todos, let's give the user the ability to mark a todo item as completed/uncompleted with a checkbox. As previously, let's start with the failing test:
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('completes a todo', async () => {
const wrapper = mount(TodoApp)
await wrapper.get('[data-test="todo-checkbox"]').setValue(true)
expect(wrapper.get('[data-test="todo"]').classes()).toContain('completed')
})
This test is similar to the previous two; we find an element and interact with it in same way (we use setValue
again, since we are interacting with a <input>
).
Lastly, we make an assertion. We will be applying a completed
class to completed todos - we can then use this to add some styling to visually indicate the status of a todo.
We can get this test to pass by updating the <template>
to include the <input type="checkbox">
and a class binding on the todo element:
<template>
<div>
<div
v-for="todo in todos"
:key="todo.id"
data-test="todo"
:class="[todo.completed ? 'completed' : '']"
>
{{ todo.text }}
<input
type="checkbox"
v-model="todo.completed"
data-test="todo-checkbox"
/>
</div>
<form data-test="form" @submit.prevent="createTodo">
<input data-test="new-todo" v-model="newTodo" />
</form>
</div>
</template>
Congratulations! You wrote your first component tests.
Arrange, Act, Assert
You may have noticed some new lines between the code in each of the tests. Let's look at the second test again, in detail:
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('creates a todo', async () => {
const wrapper = mount(TodoApp)
await wrapper.get('[data-test="new-todo"]').setValue('New todo')
await wrapper.get('[data-test="form"]').trigger('submit')
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
The test is split into three distinct stages, separated by new lines. The three stages represent the three phases of a test: arrange, act and assert.
In the arrange phase, we are setting up the scenario for the test. A more complex example may require creating a Vuex store, or populating a database.
In the act phase, we act out the scenario, simulating how a user would interact with the component or application.
In the assert phase, we make assertions about how we expect the current state of the component to be.
Almost all test will follow these three phases. You don't need to separate them with new lines like this guide does, but it is good to keep these three phases in mind as you write your tests.
Conclusion
- Use
mount()
to render a component. - Use
get()
andfindAll()
to query the DOM. trigger()
andsetValue()
are helpers to simulate user input.- Updating the DOM is an async operation, so make sure to use
async
andawait
. - Testing usually consists of 3 phases; arrange, act and assert.