Introduction
In this tutorial, we'll cover an introduction to testing vue-js applications and components. We'll be testing this simple todo application.
The source code for this application lives here.
To keep things simple, this application is built with one component, App.vue
. Here's how it looks:
// src/App.vue
<template>
<div class="container text-center">
<div class="row">
<div class="col-md-8 col-lg-8 offset-lg-2 offset-md-2">
<div class="card mt-5">
<div class="card-body">
<input data-testid="todo-input" @keyup.enter="e => editing ? updateTodo() : saveTodo()" v-model="newTodo" type="text" class="form-control p-3" placeholder="Add new todo ...">
<ul class="list-group" v-if="!editing" data-testid="todos">
<li :data-testid="`todo-${todo.id}`" class="list-group-item" v-for="todo in todos" :key="todo.id">
{{ todo.name }}
<div class="float-right">
<button :data-testid="`edit-button-${todo.id}`" class="btn btn-sm btn-primary mr-2" @click="editTodo(todo)">Edit</button>
<button :data-testid="`delete-button-${todo.id}`" class="btn btn-sm btn-danger" @click="deleteTodo(todo)">Delete</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'app',
mounted () {
this.fetchTodos()
},
data () {
return {
todos: [],
newTodo: '',
editing: false,
editingIndex: null,
apiUrl: 'https://5aa775d97f6fcb0014ee249e.mockapi.io'
}
},
methods: {
async saveTodo () {
const { data } = await axios.post(`${this.apiUrl}/todos`, {
name: this.newTodo
})
this.todos.push(data)
this.newTodo = ''
},
async deleteTodo (todo) {
await axios.delete(`${this.apiUrl}/todos/${todo.id}`)
this.todos.splice(this.todos.indexOf(todo), 1)
},
editTodo (todo) {
this.editing = true
this.newTodo = todo.name
this.editingIndex = this.todos.indexOf(todo)
},
async updateTodo () {
const todo = this.todos[this.editingIndex]
const { data } = await axios.put(`${this.apiUrl}/todos/${todo.id}`, {
name: this.newTodo
})
this.newTodo = ''
this.editing = false
this.todos.splice(this.todos.indexOf(todo), 1, data)
},
async fetchTodos () {
const { data } = await axios.get(`${this.apiUrl}/todos`)
this.todos = data
}
}
}
</script>
Brief application overview.
The application we are testing is a CRUD to-dos application.
- When the component is mounted, a
fetchTodos
function is called. This function calls an external API and gets a list of todos. - The list of to-dos is displayed in an unordered list.
- Each list item has a dynamic
data-testid
attribute generated using the unique id of the to-do. This would be used for our tests later. If you want to understand why we would use data attributes over traditional classes and ids, have a look at this. - The unordered list, input field, edit and delete buttons also have
data-testid
attributes.
Setup
- Clone the GitHub repository locally and install all npm dependencies:
git clone https://github.com/bahdcoder/testing-vue-apps
cd testing-vue-apps && npm install
- Install the packages we need for testing:
-
@vue/test-utils
package, which is the official testing library for vuejs. -
flush-promises
package, which is a simple package that flushes all pending resolved promise handlers (we'll talk more about this later).
-
npm i --save-dev @vue/test-utils flush-promises
- We'll create a mock for the
axios
library, which we'll use for our tests since we do not want to make real API requests during our tests. Create atest/__mocks__/axios.js
file, and in it, paste the following mock:
// __mocks__/axios.js
export default {
async get () {
return {
data: [{
id: 1,
name: 'first todo'
}, {
id: 2,
name: 'second todo'
}]
}
},
async post (path, data) {
return {
data: {
id: 3,
name: data.name
}
}
},
async delete (path) {},
async put (path, data) {
return {
data: {
id: path[path.length - 1],
name: data.name
}
}
}
}
Jest will automatically pick up this file, and replace it with the installed axios
library when we are running our tests. For example, the get
function returns a promise that resolves with two todos, and every time axios.get
is called in our application, jest will replace this functionality with the one in our mock.
Writing our first test
In the tests/unit
directory, create a new file called app.spec.js
, and add this to it:
// tests/unit/app.spec.js
import App from '@/App.vue'
import { mount } from '@vue/test-utils'
describe('App.vue', () => {
it('displays a list of todos when component is mounted', () => {
const wrapper = mount(App)
})
})
The first thing we did was import the App.vue
component, and mount
function from the @vue/test-utils
library.
Next, we call the mount
function passing in the App
component as a parameter.
The mount function renders the App component just like the component would be rendered in a real browser, and returns a wrapper. This wrapper contains a whole lot of helper functions for our tests as we'll see below.
As you can see, we want to test that a list of todos is fetched from the API, and displayed as an unordered list when the component is mounted.
Since we have already rendered the component by calling the mount
function on it, we'll search for the list items, and make sure they are displayed.
// app.spec.js
it('displays a list of todos when component is mounted', () => {
const wrapper = mount(App)
const todosList = wrapper.find('[data-testid="todos"]')
expect(todosList.element.children.length).toBe(2)
})
- The
find
function on the wrapper takes in aCSS selector
and finds an element in the component using that selector.
Unfortunately, running this test at this point fails because the assertions run before the fetchTodos
function resolves with the todos. To make sure our axios mock resolves with the list of to-dos before our assertion runs, we'll use our flush-promises
library as such:
// app.spec.js
import App from '@/App.vue'
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
describe('App.vue', () => {
it('displays a list of todos when component is mounted', async () => {
// Mount the component
const wrapper = mount(App)
// Wait for fetchTodos function to resolve with list of todos
await flushPromises()
// Find the unordered list
const todosList = wrapper.find('[data-testid="todos"]')
// Expect that the unordered list should have two children
expect(todosList.element.children.length).toBe(2)
})
})
The find
function returns a wrapper, and in there we can get the real DOM-element
, which is saved on the element
property. We, therefore, assert that the number of children should equal two (since our axios.get
mock returns an array of two to-dos).
Running our test now passes. Great!
Testing a user can delete a todo
Each to-do item has a delete button, and when the user clicks on this button, it should delete the todo, and remove it from the list.
// app.spec.js
it('deletes a todo and removes it from the list', async () => {
// Mount the component
const wrapper = mount(App)
// wait for the fetchTodos function to resolve with the list of todos.
await flushPromises()
// Find the unordered list and expect that there are two children
expect(wrapper.find('[data-testid="todos"]').element.children.length).toBe(2)
// Find the delete button for the first to-do item and trigger a click event on it.
wrapper.find('[data-testid="delete-button-1"]').trigger('click')
// Wait for the deleteTodo function to resolve.
await flushPromises()
// Find the unordered list and expect that there is only one child
expect(wrapper.find('[data-testid="todos"]').element.children.length).toBe(1)
// expect that the deleted todo does not exist anymore on the list
expect(wrapper.contains(`[data-testid="todo-1"]`)).toBe(false)
})
We introduced something new, the trigger
function. When we find an element using the find
function, we can trigger DOM events on this element using this function, for example, we simulate a click on the delete button by calling trigger('click')
on the found todo element.
When this button is clicked, we call the await flushPromises()
function, so that the deleteTodo
function resolves, and after that, we can run our assertions.
We also introduced a new function, contains
, which takes in a CSS selector
, and returns a boolean, depending on if that element exists in the DOM
or not.
Therefore for our assertions, we assert that the number of list items in the todos
unordered list is one, and finally also assert that the DOM does not contain the list item for the to-do we just deleted.
Testing a user can create a todo
When a user types in a new to-do and hits the enter button, a new to-do is saved to the API and added to the unordered list of to-do items.
// app.spec.js
it('creates a new todo item', async () => {
const NEW_TODO_TEXT = 'BUY A PAIR OF SHOES FROM THE SHOP'
// mount the App component
const wrapper = mount(App)
// wait for fetchTodos function to resolve
await flushPromises()
// find the input element for creating new todos
const todoInput = wrapper.find('[data-testid="todo-input"]')
// get the element, and set its value to be the new todo text
todoInput.element.value = NEW_TODO_TEXT
// trigger an input event, which will simulate a user typing into the input field.
todoInput.trigger('input')
// hit the enter button to trigger saving a todo
todoInput.trigger('keyup.enter')
// wait for the saveTodo function to resolve
await flushPromises()
// expect the the number of elements in the todos unordered list has increased to 3
expect(wrapper.find('[data-testid="todos"]').element.children.length).toBe(3)
// since our axios.post mock returns a todo with id of 3, we expect to find this element in the DOM, and its text to match the text we put into the input field.
expect(wrapper.find('[data-testid="todo-3"]').text())
.toMatch(NEW_TODO_TEXT)
})
Here's what we did:
We found the input field using its
data-testid attribute selector
, then set its value to be theNEW_TODO_TEXT
string constant. Using our trigger function, we triggered theinput
event, which is equivalent to a user typing in the input field.To save the todo, we hit the enter key, by triggering the
keyup.enter
event. Next, we call theflushPromises
function to wait for thesaveTodo
function to resolve.-
At this point, we run our assertions:
- First, we find the unordered list and expect that it now has three to-dos: two from calling the
fetchTodos
function when the component is mounted, and one from just creating a new one. - Next, using the
data-testid
, we find the specific to-do that was just created ( we usetodo-3
because our mock of theaxios.post
function returns a new todo item with theid
of 3). - We assert that the text in this list item equals the text we typed in the input box at the beginning of the text.
- Note that we use the
.toMatch()
function because this text also contains theEdit
andDelete
texts.
- First, we find the unordered list and expect that it now has three to-dos: two from calling the
Testing a user can update a todo
Testing for the update process is similar to what we've already done. Here it is:
// app.spec.js
it('updates a todo item', async () => {
const UPDATED_TODO_TEXT = 'UPDATED TODO TEXT'
// Mount the component
const wrapper = mount(App)
// Wait for the fetchTodos function to resolve
await flushPromises()
// Simulate a click on the edit button of the first to-do item
wrapper.find('[data-testid="edit-button-1"]').trigger('click')
// make sure the list of todos is hidden after clicking the edit button
expect(wrapper.contains('[data-testid="todos"]')).toBe(false)
// find the todo input
const todoInput = wrapper.find('[data-testid="todo-input"]')
// set its value to be the updated texr
todoInput.element.value = UPDATED_TODO_TEXT
// trigger the input event, similar to typing into the input field
todoInput.trigger('input')
// Trigger the keyup event on the enter button, which will call the updateTodo function
todoInput.trigger('keyup.enter')
// Wait for the updateTodo function to resolve.
await flushPromises()
// Expect that the list of todos is displayed again
expect(wrapper.contains('[data-testid="todos"]')).toBe(true)
// Find the todo with the id of 1 and expect that its text matches the new text we typed in.
expect(wrapper.find('[data-testid="todo-1"]').text()).toMatch(UPDATED_TODO_TEXT)
})
Running our tests now should be successful. Awesome!
Top comments (4)
Cool, but how do you set up webpack to remove the
data-testid
's from the dist bundle?I don't do that at the moment because there's no value to removing it, and there's no disadvantage to leaving it there. What do you think about this ?
Figured it out: github.com/vuejs/vue-test-utils/is...
Thanks for the tut! i was looking for a way to access an element by its
data-*
attribute in a test case and your article showed me the way to do just that i-e by usingwrapper.find('[data-testid="todos"]')
.