Testing Vue.js Applications
In a world with tight deadliness and accelerating requirements, creating automated tests for our applications becomes more important than ever. An important factor to consider, which most developers overlook, is the fact that testing is a skill, and just because you may be comfortable coding up solutions, it doesn't automatically mean that you can write good unit tests. As you get more experience in this area, you'll find yourself writing tests more often and wonder what you ever did without them!
By the end of this chapter, we will cover the following:
- Learning about why you should consider using automated testing tools and techniques
- Writing your first unit test for Vue components
- Writing tests that mock out particular functions
- Writing tests that are dependent on Vue.js events
- Using Wallaby.js to see the results of our tests in real time
When we talk about testing our Vue projects, we can mean different thing
Why testing?
Automated testing tools exist for a reason. When it comes to testing the work that we've created manually, you'll know from experience that this is a long, (sometimes complex) process that does not allow for consistent results. Not only do we have to manually remember whether a particular component works (or otherwise write the results down somewhere!), but it isn't resilient to change.
Here are some phrases I've heard over the years when testing has been brought up:
"But Paul, if I write tests for my application it'll take three times as long!"
"I don't know how to write tests..."
"That's not my job!"
...and a variety of others.
The point is that testing is a skill in the same sense that development is a skill. You may not be immediately great at one or the other, but with time, practice, and perseverance, you should find yourself in a position where testing feels natural and a normal part of software development.
Unit testing
Automated testing tools take the manual work we'd be doing each time we want to verify that our feature works as expected, and give us a way to run a command that tests our assertions one by one. This is then shown to us in reports (or live in our editor, as we'll see later on), which gives us the ability to refactor code that isn't working as intended.
By using automated testing tools, we're saving ourselves a vast amount of effort when compared to manual testing.
Unit testing can be defined as a type of testing that only tests one "unit" (the smallest testable part of a feature) at a time. We can then automate this process to continually test our features as the application gets larger. At this point, you may wish to follow Test-Driven Development/Behavior Driven-Development practices.
In the modern JavaScript testing ecosystem, there are a variety of test suites available. These test suites can be thought of as applications that give us the ability to write assertions, run our tests, provide us with coverage reports, and much more. We'll be using Jest inside our project, as this is a fast and flexible suite created and maintained by Facebook.
Let's create a new playground project so that we can get familiar with Unit testing. We'll be using thewebpacktemplate instead of thewebpack-simpletemplate, as this allows us to configure testing by default:
# Create a new Vue project
$ vue init webpack vue-testing
? Project name vue-testing
? Project description Various examples of testing Vue.js applications
? Author Paul Halliday <[email protected]>
? Vue build runtime
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Airbnb
? Set up unit tests Yes
? Pick a test runner jest
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been create
d? (recommended) npm
# Navigate to directory
$ cd vue-testing
# Run application
$ npm run dev
Let's start off by investigating thetest/unit/specsdirectory. This is where we'll be placing all of our unit/integration tests when testing our Vue components. Open upHelloWorld.spec.js, and let's go through it line by line:
// Importing Vue and the HelloWorld component
import Vue from 'vue';
import HelloWorld from '@/components/HelloWorld';
// 'describe' is a function used to define the 'suite' of tests (i.e.overall context).
describe('HelloWorld.vue', () => {
//'it' is a function that allows us to make assertions (i.e. test
true/false)
it('should render correct contents', () => {
// Create a sub class of Vue based on our HelloWorld component
const Constructor = Vue.extend(HelloWorld);
// Mount the component onto a Vue instance
const vm = new Constructor().$mount();
// The h1 with the 'hello' class' text should equal 'Welcome to
Your Vue.js App'
expect(vm.$el.querySelector('.hello h1').textContent).toEqual(
'Welcome to Your Vue.js App',
);
});
});
We can then run these tests by runningnpm run unitinside our Terminal (ensure that you're in the project directory). This will then tell us how many tests have passed as well as the overall test code coverage. This metric can be used as a way to determine how robust an application is in 60; most circumstances; however, it should not be used as gospel. In the following screenshot, we canclearlysee how many of our tests have passed:
Setting up vue-test-utils
For a better testing experience, it's advised to use thevue-test-utilsmodule as this provides us with many helpers and patterns that are exclusively used with the Vue framework. Let's create a new project based on thewebpack-simpletemplate and integrate Jest andvue-test-utilsourselves. Run the following in your Terminal:
# Create a new Vue project
$ vue init webpack-simple vue-test-jest
# Navigate to directory
$ cd vue-test-jest
# Install dependencies
$ npm install
# Install Jest and vue-test-utils
$ npm install jest vue-test-utils --save-dev
# Run application
$ npm run dev
Then, we have to add some extra configuration to our project so that we can run Jest, our test suite. This can be configured inside our project'spackage.json. Add the following:
{
"scripts": {
"test": "jest"
}
}
This means that any time we want to run our tests, we simply runnpm run testinside our Terminal. This runs the local (project installed) version of Jest on any files that match the*.spec.jsname.
Next, we need to tell Jest how to handle Single File Components (that is,*.vuefiles) within our project. This requires thevue-jestpreprocessor. We'll also want to use ES2015+ syntax inside of our tests, so we'll also need thebabel-jestpreprocessor. Let's install both by running the following in the Terminal:
npm install --save-dev babel-jest vue-jest
We can then define the following object insidepackage.json:
"jest": {
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"moduleFileExtensions": [
"js",
"vue"
],
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/babel-jest",
".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
}
}
This is essentially telling Jest how to handle both JavaScript and Vue files, by knowing which preprocessor to use (that is,babel-jestorvue-jest), depending on the context.
We can also make our tests run quicker if we tell Babel to only transpile features for the Node version we're currently loading. Let's add a separate test environment to our.babelrcfile:
{
"presets": [["env", { "modules": false }], "stage-3"],
"env": {
"test": {
"presets": [["env", { "targets": { "node": "current" } }]]
}
}
}
Now that we've added the appropriate configuration, let's start testing!
Creating a TodoList
Let's now create aTodoList.vuecomponent insidesrc/componentsfolder. This is the component that we will be testing, and we'll slowly add more features to it:
<template>
<div>
<h1>Todo List</h1>
<ul>
<li v-for="todo in todos" v-bind:key="todo.id">
{{todo.id}}. {{todo.name}}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
todos: [
{ id: 1, name: 'Wash the dishes' },
{ id: 2, name: 'Clean the car' },
{ id: 3, name: 'Learn about Vue.js' },
],
};
},
};
</script>
<style>
ul,
li {
list-style: none;
margin-left: 0;
padding-left: 0;
}
</style>
As you can see, we just have a simple application that returns an array of to-dos with varying items. Let's create a router insidesrc/router/index.jsto match our newTodoListcomponent and display it as the root:
import Vue from 'vue';
import Router from 'vue-router';
import TodoList from '../components/TodoList';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/',
name: 'TodoList',
component: TodoList,
},
],
});
As we're usingvue-router, we'll also need to install it. Run the following in your Terminal:
$ npm install vue-router --save-dev
We can then add the router tomain.js:
import Vue from 'vue'
import App from './App.vue'
import router from './router';
new Vue({
el: '#app',
router,
render: h => h(App)
})
I've now addedrouter-viewand elected to remove the Vue logo fromApp.vue, so we have a cleaner user interface. Here's the template forApp.vue:
<template>
<div id="app">
<router-view/>
</div>
</template>
As we can see in our browser, it displays our template with the name ofTodoListand thetodoitems we created as well:
Let's write some tests for this component
Writing tests
Inside thesrc/componentsfolder, make a new folder named__tests__and then create a file namedTodoList.spec.js. Jest will automatically find this folder and subsequent tests.
Let's first import our component and themountmethod from the test utilities:
import { mount } from 'vue-test-utils';
import TodoList from '../TodoList';
Themountmethod allows us to test ourTodoListcomponent in isolation and gives us the ability to mock any input props, events, and even outputs. Next, let's create a describe block that we'll use to contain our test suite:
describe('TodoList.vue', () =
>
{
});
Let's now mount the component and gain access to the Vue instance:
describe('TodoList.vue', () =
>
{
// Vue instance can be accessed at wrapper.vm
const wrapper = mount(TodoList);
});
Next, we need to define theitblock to assert the outcome of our test case. Let's make our first expectation—it should render a list of to-do items:
describe('TodoList.vue', () => {
const todos = [{ id: 1, name: 'Wash the dishes' }];
const wrapper = mount(TodoList);
it('should contain a list of Todo items', () => {
expect(wrapper.vm.todos).toContainEqual(todos[0]);
});
});
Wecanwatchchangesforourtestsbyrunning$ npm run test -- --watchAllintheTerminal.Alternatively, we can make a new script insidepackage.jsonthat does this for us:
"scripts": {
"test:watch": "jest --watchAll"
}
Now, if we runnpm run test:watchinside of the Terminal, it will watch the filesystem for any changes.
Here are our results:
That's interesting. We have a passing test! However, we have to think to ourselves at this point, is this test brittle? In a real-world application, we may not have items inside ourTodoListat runtime by default.
We need a way to set properties on our isolated tests. This is where the ability to set our own Vue options comes in handy!
Vue options
We can set our own options on a Vue instance. Let's usevue-test-utilsto set our own data on the instance and see whether this data is being rendered on screen:
describe('TodoList.vue', () => {
it('should contain a list of Todo items', () => {
const todos = [{ id: 1, name: 'Wash the dishes' }];
const wrapper = mount(TodoList, {
data: { todos },
});
// Find the list items on the page
const liWrapper = wrapper.find('li').text();
// List items should match the todos item in data
expect(liWrapper).toBe(todos[0].name);
});
});
As we can see, we're now testing against the itemsrenderedon the screen based on the data option within our component.
Let's add aTodoItemcomponent so that we can render a component with atodoprop dynamically. We can then test this component's output based on our prop:
<template>
<li>{{todo.name}}</li>
</template>
<script>
export default {
props: ['todo'],
};
</script>
We can then add it to theTodoListcomponent:
<template>
<div>
<h1>TodoList</h1>
<ul>
<TodoItem v-for="todo in todos" v-bind:key="todo.id"
:todo="todo">{{todo.name}}</TodoItem>
</ul>
</div>
</template>
<script>
import TodoItem from './TodoItem';
export default {
components: {
TodoItem,
},
// Omitted
}
Our tests still pass as expected, because the component is rendered intoliat runtime. It may be a better idea to change this to find the component itself though:
import { mount } from 'vue-test-utils';
import TodoList from '../TodoList';
import TodoItem from '../TodoItem';
describe('TodoList.vue', () => {
it('should contain a list of Todo items', () => {
const todos = [{ id: 1, name: 'Wash the dishes' }];
const wrapper = mount(TodoList, {
data: { todos },
});
// Find the list items on the page
const liWrapper = wrapper.find(TodoItem).text();
// List items should match the todos item in data
expect(liWrapper).toBe(todos[0].name);
});
});
Let's write some tests for ourTodoItemand create aTodoItem.spec.jsinsidecomponents/__tests__:
import { mount } from 'vue-test-utils';
import TodoItem from '../TodoItem';
describe('TodoItem.vue', () => {
it('should display name of the todo item', () => {
const todo = { id: 1, name: 'Wash the dishes' };
const wrapper = mount(TodoItem, { propsData: { todo } });
// Find the list items on the page
const liWrapper = wrapper.find('li').text();
// List items should match the todos item in data
expect(liWrapper).toBe(todo.name);
});
});
As we're essentially using the same logic, our test is similar. The main difference is that instead of having a list oftodos, we just have onetodoobject. We're mocking the props withpropsDatainstead of data, essentially asserting that we can add properties to this component and it renders the correct data. Let's take a look at whether our tests passed or failed:
Adding new features
Let's take a test-driven approach to adding new features to our application. We'll need a way to add new items to ourtodolist, so let's start by writing our tests first. InsideTodoList.spec.js, we'll add anotheritassertion that should add an item to ourtodolist:
it('should add an item to the todo list', () => {
const wrapper = mount(TodoList);
const todos = wrapper.vm.todos;
const newTodos = wrapper.vm.addTodo('Go to work');
expect(todos.length).toBeLessThan(newTodos.length);
});
If we run our tests right now, we'll get a failing test this is expected!:
Let's do the minimum possible to fix our error. We can add a method namedaddTodoinside our Vue instance:
export default {
methods: {
addTodo(name) {},
},
// Omitted
}
Now we get a new error; this time, it states that itCannot read property 'length' of undefined, essentially saying that we have nonewTodosarray:
Let's make ouraddTodofunction return an array that combines the currenttodoswith a new todo:
addTodo(name) {
return [...this.todos, { name }]
},
We get this output after runningnpm test:
Ta-da! Passing tests.
Hmm. I do remember all of ourtodoitems having an appropriateid, but it looks like that's no longer the case.
Ideally, our server-side database should handleidnumbers for us, but for now, we can work with a client-side generateduuidusing theuuidpackage. Let's install it by running the following in the Terminal:
$ npm install uuid
We can then write our test case to assert that each item added to the list has anidproperty:
it('should add an id to each todo item', () => {
const wrapper = mount(TodoList);
const todos = wrapper.vm.todos;
const newTodos = wrapper.vm.addTodo('Go to work');
newTodos.map(item => {
expect(item.id).toBeTruthy();
});
});
As you can see, the Terminal outputs that we have an issue, and this is caused because we evidently don't have anidproperty:
Let's use theuuidpackage we installed earlier to achieve this goal:
import uuid from 'uuid/v4';
export default {
methods: {
addTodo(name) {
return [...this.todos, { id: uuid(), name }];
},
},
// Omitted
};
We then get a passing test:
Starting off with a failing test is beneficial for multiple reasons:
- It ensures that our test is actually running and we don't spend time debugging anything!
- We know what we need to implement next, as we're driven by the current error message
We can then write the minimum necessary to get a green test and continue to refactor our code until we're satisfied with our solution. In the previous tests, we could have written even less to get a green result, but for brevity, I've elected for smaller examples.