DEV Community

loading...
Cover image for Vue Testing Crash Course
Emarsys Craftlab

Vue Testing Crash Course

blacksonic profile image Gábor Soós Updated on ・10 min read

You have nearly finished your project, and only one feature is left. You implement the last one, but bugs appear in different parts of the system. You fix them, but another one pops up. You start playing a whack-a-mole game, and after multiple turns, you feel messed up. But there is a solution, a life-saver that can make the project shine again: write tests for the future and already existing features. This guarantees that working features stay bug-free.

In this tutorial, I'll show you how to write unit, integration and end-to-end tests for Vue applications.

For more test examples, you can take a look at my Vue TodoApp implementation.

1. Types

Tests have three types: unit, integration and end-to-end. These test types are often visualized as a pyramid.

Testing Pyramid

The pyramid indicates that tests on the lower levels are cheaper to write, faster to run and easier to maintain. Why don't we write only unit tests then? Because tests on the upper end give us more confidence about the system and they check if the components play well together.

To summarize the difference between the types of tests: unit tests only work with a single unit (class, function) of code in isolation, integration tests check if multiple units work together as expected (component hierarchy, component + store), while end-to-end tests observe the application from the outside world (browser).

2. Test runner

For new projects, the easiest way to add testing to your project is through the Vue CLI. When generating the project (vue create myapp), you have to manually select Unit Testing and E2E Testing.

Vue CLI

When the installation is finished, multiple additional dependencies will appear in your package.json file:

  • @vue/cli-plugin-unit-mocha: plugin for unit/integration tests with Mocha
  • @vue/test-utils: helper library for unit/integration testing
  • chai: assertion library Chai

From now on, unit/integration tests can be written in the tests/unit directory with *.spec.js suffix. The directory of the tests is not hardwired; you can modify it with a command-line argument:

vue-cli-service test:unit --recursive 'src/**/*.spec.js'
Enter fullscreen mode Exit fullscreen mode

The recursive parameter tells the test runner to search for the test files based on the following glob pattern.

3. Single unit

So far, so good, but we haven't written any tests yet. Let's write our first unit test!

describe('toUpperCase', () => {
  it('should convert string to upper case', () => {
    // Arrange
    const toUpperCase = info => info.toUpperCase();

    // Act
    const result = toUpperCase('Click to modify');

    // Assert
    expect(result).to.eql('CLICK TO MODIFY');
  });
});
Enter fullscreen mode Exit fullscreen mode

This verifies if the toUpperCase function converts the given string to upper case.

The first thing to do (arrange) is to get the target (here a function) into a testable state. It can mean importing the function, instantiating an object, and setting its parameters. The second thing is to execute that function/method (act). Finally, after the function has returned the result, we make assertions for the outcome.

Mocha gives us two functions describe and it. With the describe function we can organize our test cases around units: a unit can be a class, a function, component, etc. Mocha doesn't have a built-in assertion library, that's why we have to use Chai: it can set expectations on the outcome. Chai has many different built-in assertions. These assertions, however, do not cover all use-cases. Those missing assertions can be imported with Chai's plugin system, adding new types of assertions to the library.

Most of the time, you will be writing unit tests for the business logic that resides outside of the component hierarchy, for example, state management or backend API handling.

4. Component display

The next step is to write an integration test for a component. Why is it an integration test? Because we no longer test only the Javascript code, but rather the interaction between the DOM as well as the corresponding component logic.

// src/components/Footer.vue
<template>
  <p class="info">{{ info }}</p>
  <button @click="modify">Modify</button>
</template>
<script>
  export default {
    data: () => ({ info: 'Click to modify' }),
    methods: {
      modify() {
        this.info = 'Modified by click';
      }
    }
  };
</script>
Enter fullscreen mode Exit fullscreen mode

The first component we test is one that displays its state and modifies the state if we click the button.

// test/unit/components/Footer.spec.js
import { expect } from 'chai';
import { shallowMount } from '@vue/test-utils';
import Footer from '@/components/Footer.vue';

describe('Footer', () => {
  it('should render component', () => {
    const wrapper = shallowMount(Footer);

    const text = wrapper.find('.info').text();
    const html = wrapper.find('.info').html();
    const classes = wrapper.find('.info').classes();
    const element = wrapper.find('.info').element;

    expect(text).to.eql('Click to modify');
    expect(html).to.eql('<p class="info">Click to modify</p>');
    expect(classes).to.eql(['info']);
    expect(element).to.be.an.instanceOf(HTMLParagraphElement);
  });
});
Enter fullscreen mode Exit fullscreen mode

To render a component in a test, we have to use shallowMount or mount from Vue Test Utils. Both methods render the component, but shallowMount doesn't render its child components (child elements will be empty elements). When including the component under test, we can reference it relatively ../../../src/components/Footer.vue or use the provided alias @. The @ sign at the start of the path references the source folder src.

We can search in the rendered DOM with the find selector and retrieve its HTML, text, classes or native DOM element. If we are searching for a non-existing fragment, the exists method can tell if it exists. It is enough to write one of the assertions; they stand there only to show the different possibilities.

5. Component interactions

We have tested what can we see in the DOM, but we haven't made any interactions with the component. We can interact with a component through the component instance or the DOM.

it('should modify the text after calling modify', () => {
  const wrapper = shallowMount(Footer);

  wrapper.vm.modify();

  expect(wrapper.vm.info).to.eql('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

The above example shows how to do it with the component instance. We can access the component instance with the vm property. Functions under methods and properties on the data object (state) are available on the instance. In this case, we don't touch the DOM.

The other way is to interact with the component through the DOM. We can trigger a click event on the button and observe the displayed text.

it('should modify the text after clicking the button', () => {
  const wrapper = shallowMount(Footer);

  wrapper.find('button').trigger('click');
  const text = wrapper.find('.info').text();

  expect(text).to.eql('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

We trigger the click event on the button, and it results in the same outcome as we have called the modify method on the instance.

6. Parent-child interactions

We have examined a component separately, but a real-world application consists of multiple parts. Parent components talk to their children through props, and children talk to their parents through emitted events.

Let's modify the component that it receives the display text through props and notifies the parent component about the modification through an emitted event.

export default {
  props: ['info'],
  methods: {
    modify() {
      this.$emit('modify', 'Modified by click');
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

In the test, we have to provide the props as input and listen to the emitted events.

it('should handle interactions', () => {
  const wrapper = shallowMount(Footer, {
    propsData: { info: 'Click to modify' }
  });

  wrapper.vm.modify();

  expect(wrapper.vm.info).to.eql('Click to modify');
  expect(wrapper.emitted().modify).to.eql([
    ['Modified by click']
  ]);
});
Enter fullscreen mode Exit fullscreen mode

The method shallowMount and mount has a second optional parameter, where we can set the input props with propsData. The emitted events become available from the emitted methods result. The name of the event will be the object key, and each event will be an entry in the array.

7. Store integration

In the previous examples, the state was always inside the component. In complex applications, we need to access and mutate the same state in different locations. Vuex, the state management library for Vue, can help you organize state management in one place and ensure it mutates predictably.

const store = {
  state: {
    info: 'Click to modify'
  },
  actions: {
    onModify: ({ commit }, info) => commit('modify', { info })
  },
  mutations: {
    modify: (state, { info }) => state.info = info
  }
};
const vuexStore = new Vuex.Store(store);
Enter fullscreen mode Exit fullscreen mode

The store has a single state property, which is the same what we have seen on the component. We can modify the state with the onModify action that passes the input parameter to the modify mutation and mutates the state.

We can start by writing unit tests separately for each function in the store.

it('should modify state', () => {
  const state = {};

  store.mutations.modify(state, { info: 'Modified by click' });

  expect(state.info).to.eql('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

Or we can construct the store and write an integration test. This way, we can check if the methods play together instead of throwing errors.

import Vuex from 'vuex';
import { createLocalVue } from '@vue/test-utils';

it('should modify state', () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const vuexStore = new Vuex.Store(store);

  vuexStore.dispatch('onModify', 'Modified by click');

  expect(vuexStore.state.info).to.eql('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

First, we have to create a local instance of Vue. Why is it needed? The use statement is necessary on the Vue instance for the store. If we don't call the use method, it will throw an error. By creating a local copy of Vue, we also avoid polluting the global object.

We can alter the store through the dispatch method. The first parameter tells which action to call; the second parameter is passed to the action as a parameter. We can always check the current state through the state property.

When using the store with a component, we have to pass the local Vue instance and the store instance to the mount function.

const wrapper = shallowMount(Footer, { localVue, store: vuexStore });
Enter fullscreen mode Exit fullscreen mode

8. Routing

The setup for testing routing is a bit similar to testing the store. You have to create a local copy of the Vue instance, an instance of the router, use the router as a plugin and then create the component.

<div class="route">{{ $router.path }}</div>
Enter fullscreen mode Exit fullscreen mode

The above line in the component's template will display the current route. In the test, we can assert the content of this element.

import VueRouter from 'vue-router';
import { createLocalVue } from '@vue/test-utils';

it('should display route', () => {
  const localVue = createLocalVue();
  localVue.use(VueRouter);
  const router = new VueRouter({
    routes: [
      { path: '*', component: Footer }
    ]
  });

  const wrapper = shallowMount(Footer, { localVue, router });
  router.push('/modify');
  const text = wrapper.find('.route').text();

  expect(text).to.eql('/modify');
});
Enter fullscreen mode Exit fullscreen mode

We have added our component as a catch-them-all route with the * path. When we have the router instance, we have to programmatically navigate the application with the router's push method.

Creating all the routes can be a time-consuming task. We can speed up the orchestration with a fake router implementation and pass it as a mock.

it('should display route', () => {
  const wrapper = shallowMount(Footer, {
    mocks: {
      $router: {
        path: '/modify'
      }
    }
  });
  const text = wrapper.find('.route').text();

  expect(text).to.eql('/modify');
});
Enter fullscreen mode Exit fullscreen mode

We can use this mocking technique also for the store by declaring the $store property on mocks.

it('should display route', () => {
  const wrapper = shallowMount(Footer, {
    mocks: {
      $router: {
        path: '/modify'
      },
      $store: {
        dispatch: sinon.stub(),
        commit: sinon.stub(),
        state: {}
      }
    }
  });
  const text = wrapper.find('.route').text();
  expect(text).to.eql('/modify');
});
Enter fullscreen mode Exit fullscreen mode

9. HTTP requests

Initial state mutation often comes after an HTTP request. While it is tempting to let that request reach its destination in a test, it would also make the test brittle and dependant on the outside world. To avoid this, we can change the request’s implementation at runtime, which is called mocking. We will use the Sinon mocking framework for it.

const store = {
  actions: {
    async onModify({ commit }, info) {
      const response = await axios.post('https://example.com/api', { info });
      commit('modify', { info: response.body });
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

We have modified the store implementation: the input parameter is first sent through a POST request, and then the result is passed to the mutation. The code becomes asynchronous and gets an external dependency. The external dependency will be the one we have to change (mock) before running the test.

import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);

it('should set info coming from endpoint', async () => {
  const commit = sinon.stub();
  sinon.stub(axios, 'post').resolves({
    body: 'Modified by post'
  });

  await store.actions.onModify({ commit }, 'Modified by click');

  expect(commit).to.have.been.calledWith('modify', { info: 'Modified by post' });
});
Enter fullscreen mode Exit fullscreen mode

We are creating a fake implementation for the commit method and change the original implementation of axios.post. These fake implementations capture the arguments passed to them and can respond with whatever we tell them to return. The commit method returns with an empty value because we haven't specified one. axios.post will return with a Promise that resolves to an object with the body property.

We have to add Sinon as a plugin to Chai to be able to make assertions for the call signatures. The plugin extends Chai with the to.have.been.called property and to.have.been.calledWith method.

The test function becomes asynchronous: Mocha can detect and wait for the asynchronous function to complete if we return a Promise. Inside the function, we wait for the onModify method to complete and then make an assertion wether the fake commit method was called with the parameter returned from the post call.

10. The browser

From a code perspective, we have touched every aspect of the application. There is a question we still can’t answer: can the application run in the browser? End-to-end tests written with Cypress can answer the question.

The Vue CLI takes care of the orchestration: starts the application and runs the Cypress tests in the browser, and then shuts down the application. If you want to run the Cypress tests in headless mode, you have to add the --headless flag to the command.

describe('New todo', () => {
  it('it should change info', () => {
    cy.visit('/');

    cy.contains('.info', 'Click to modify');

    cy.get('button').click();

    cy.contains('.info', 'Modified by click');
  });
});
Enter fullscreen mode Exit fullscreen mode

The organization of the tests is the same as with unit tests: describe stands for grouping, it stands for running the tests. We have a global variable, cy, which represents the Cypress runner. We can command the runner synchronously about what to do in the browser.

After visiting the main page (visit), we can access the displayed HTML through CSS selectors. We can assert the contents of an element with contains. Interactions work the same way: first, select the element (get) and then make the interaction (click). At the end of the test, we check if the content has changed or not.

Summary

We have reached the end of testing use-cases. I hope you enjoyed the examples and they clarified many things around testing. I wanted to lower the barrier of starting to write tests for a Vue application. We have gone from a basic unit test for a function to an end-to-end test running in a real browser.

Through our journey, we have created integration tests for the building blocks of a Vue application (components, store, router) and scratched the surface of implementation mocking. With these techniques, your existing and future projects can stay bug-free.

Header image by Louis Reed on Unsplash

Discussion (3)

pic
Editor guide
Collapse
bircow profile image
Libor Jelinek

Excellent post. Easy, well written, thanks!

Collapse
andreiculda profile image
andreiculda

Very nice article. Right on the spot.

Collapse
marcotterra profile image
Marco

Great article!
I was searching for a compiled text like this, thanks !