DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for A gentle introduction to testing vue applications.
Kati Frantz
Kati Frantz

Posted on • Updated on

A gentle introduction to testing vue applications.

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>
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
  • 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

Enter fullscreen mode Exit fullscreen mode
  • 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 a test/__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
      }
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

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)
  })
})


Enter fullscreen mode Exit fullscreen mode

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)
  })

Enter fullscreen mode Exit fullscreen mode
  • The find function on the wrapper takes in a CSS 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)
  })
})



Enter fullscreen mode Exit fullscreen mode

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)
  })

Enter fullscreen mode Exit fullscreen mode

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)
  })


Enter fullscreen mode Exit fullscreen mode

Here's what we did:

  • We found the input field using its data-testid attribute selector, then set its value to be the NEW_TODO_TEXT string constant. Using our trigger function, we triggered the input 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 the flushPromises function to wait for the saveTodo 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 use todo-3 because our mock of the axios.post function returns a new todo item with the id 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 the Edit and Delete texts.

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)
  })

Enter fullscreen mode Exit fullscreen mode

Running our tests now should be successful. Awesome!

Top comments (4)

Collapse
thejaredwilcurt profile image
The Jared Wilcurt

Cool, but how do you set up webpack to remove the data-testid's from the dist bundle?

Collapse
bahdcoder profile image
Kati Frantz Author

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 ?

Collapse
thejaredwilcurt profile image
The Jared Wilcurt
Collapse
omeryousaf profile image
Omer Yousaf

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 using wrapper.find('[data-testid="todos"]').

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.