Unit testing is a software testing method where the individual units/ components of a software are tested to verify that each unit works as expected.
This guide will walk through creating a project with Vue CLI 3, using testing solutions, writing and running test cases while creating a basic single page application. It would be helpful to get the source code from the GitHub repository while following this post.
Link for the repo: https://github.com/adityaekawade/Vue.js-unit-test-with-jest
Concept of the app
This will be a basic weather app where the user can enter a zip code and get the current temperature in that region. We will be breaking it down into individual component and write test cases for each component as we go on building it.
The app will be using the openweathermap API to make a http request to get the data. Api docs: https://openweathermap.org/current
Vue CLI 3 Installation
I will be using Vue CLI 3, as it is easier to use testing with Vue CLI 3. It also includes vue-test-utils
which is a great library that makes testing a lot easier in vue. It also provides the hot reloading which speeds up the development process.
To install Vue CLI
npm install -g @vue/cli
To create a new project
vue create unit-test-project
Once we hit this, it will ask for some information. Choose Manually select features and enable “Unit testing” along with other required features and continue. I have also selected a CSS pre-processor (Sass/ SCSS).
Next, it will ask to select a “Unit testing solution”. Select “Jest” here. It will then ask to either save the configuration in package.json or in the default config file and also if you want to save this configuration as a preset.
Next, navigate to the project folder and start the app:
cd unit-test-project
npm run serve
This builds the project, and you should be able to launch it on localhost:8080
.
The project structure is as follows:
- Component files are present in
src/components
directory. Eg:HelloWorld.vue
- Test files are present in
test/unit
directory. Eg:example.spec.js
The example.spec.js
file is the default test that comes with vue cli 3. It expects the text on the HelloWorld
component to match the data received as props from its parent App.vue
.
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
expect(wrapper.text()).toMatch(msg)
})
})
Each test file will have describe()
and it()
.
describe()
is usually the name of the component you are testing- Each
it()
is a different test case.
wrapper
: A Wrapper is an object that contains a mounted component or vnode and methods to test the component or vnode.
shallowMount( )
: It creates a wrapper that contains the mounted and rendered vue component. But it only mounts the one component. Here only HelloWorld
component is mounted. There is another type mount( )
, which does similar thing but it would also mount the child components.
To run the test from the command line:
npm run test:unit
It will run the unit tests and you should be able to see the following result:
Setting up for the existing project:
Ref: https://vue-test-utils.vuejs.org/guides/testing-single-file-components-with-jest.html
Vue UI
Vue cli 3 also comes with a project management user interface which can be used to install dependencies/ plugins, to serve the app as well as run the tests.
To start the Vue UI
vue ui
This will open up the Vue UI tool. You can import the newly created project.
To serve the project go to Tasks > serve
and click Run Task
. Once compiled you can open the app by clicking Open app
.
Installing Vuetify plugin
I will install the Vuetify plugin, since this project will use Vuetify, a material design component framework.
Go to Plugins
in the Vue UI, click Add plugin
and search for vuetify
.
Select the first option and click install
.
It will then ask to choose a preset. You can keep the Default
and finish the installation.
If you visit the app you should be able to see the landing page is now updated with vuetify desing and content. The unit test would fail since the app content is changed.
Component Structure
The following images show the structure of the components of the app. Here ZipCodeInput
and DisplayTemperature
are the child components of the Home
. ZipCodeInput
takes the input (zip code) and DisplayTemperature
shows the output (temperature).
Writing Unit Tests!
You can write unit tests before you write your code, after you write your code or while you write your code.
Before we get started, this Stack Exchange thread provides a great reading material about writing unit test and while following the Test Driven Development (TDD) approach.
So, I have cleaned the files by renaming the HelloWorld.vue
file to Home.vue
and removing all its content. Also, I have deleted the default example.spec.js
test file.
To start with, I will create a test file named Home.spec.js
file in the tests
directory which will have the test cases for the <Home>
component. It follows a convention to create a test file with the name COMPONENT_NAME.spec.js
and placing it under tests/unit
directory.
I would want to test the following:
- If the
Home
component is an instance of Vue - This component has the text “Weather App” which would be set as a data property.
- The text “Weather App” would be a heading 2 text. So, I will test to
check if the component contains an
h2
tag.
The test code will look as follows:
//tests/unit/Home.spec.js
import { createLocalVue, mount, shallowMount } from '@vue/test-utils'
import Home from '@/components/Home.vue'
import vuetify from "vuetify"
describe('Home.vue', () => {
let wrapper;
let title = 'Weather App'
beforeEach(()=>{
const localVue = createLocalVue()
localVue.use(vuetify)
wrapper = shallowMount(Home, {
localVue
});
})
it('renders a vue instance', () => {
expect(shallowMount(Home).isVueInstance()).toBe(true);
});
it('Checks the data-title', () => {
expect(wrapper.vm.title).toMatch('Weather App')
})
it('has an h2', ()=>{
expect(wrapper.contains('h2')).toBe(true)
})
})
createLocalVue()
allows adding components, mixins, installed plugins (Eg. Vuetify) without polluting the global vue class.
Running these tests would show the following results:
Next, we will write the code to pass the two failed tests.
//Home.vue
<template>
<v-container>
<v-flex xs12>
<h2></h2>
</v-flex>
</v-container>
</template>
<script>
export default {
data: () => ({
title: "Weather App",
})
}
</script>
This will pass the two failed tests.
However, you might also notice the following console error:
console.error node_modules/vuetify/dist/vuetify.js:25204
[Vuetify] Multiple instances of Vue detected
See https://github.com/vuetifyjs/vuetify/issues/4068
There seems to be an issue with Vuetify that causes multiple instances of vue
when mounted with localVue
. You can overcome this problem by not using the localVue
.
The test would then look like:
import { createLocalVue, mount, shallowMount } from '@vue/test-utils'
import Home from '@/components/Home.vue'
import vuetify from "vuetify"
import Vue from 'vue';
describe('Home.vue', () => {
let wrapper;
let title = 'Weather App'
beforeEach(()=>{
Vue.use(vuetify);
wrapper = shallowMount(Home);
})
it('renders a vue instance', () => {
expect(shallowMount(Home).isVueInstance()).toBe(true);
});
})
But since the documentation recommends using localVue
, I will continue with localVue
.
Creating child components
I will create two files ZipCodeInput.vue
, DisplayTemperature.vue
and import them in Home.vue
as its child components. As mentioned earlier the ZipCodeInput.vue
will be taking the input from the user and DisplayTemperature.vue
will be displaying the output
Once imported I will check if the Home
component contains the child component. The test case would look like:
//Home.spec.js
import ZipCodeInput from '@/components/ZipCodeInput.vue'
…
…
…
it('check if child ZipCodeInput exists', ()=>{
expect(wrapper.contains(ZipCodeInput)).toBe(true);
})
Creating ZipCodeInput.vue
As planned earlier (Refer to the image in Component Structure section above ), this component should have the following:
- An input field where the user can enter the zip code.
- A button to submit the input.
The test file would for this behavior would look like:
//tests/ ZipCodeInput.spec.js
import { createLocalVue, mount } from '@vue/test-utils'
import ZipCodeInput from '@/components/ZipCodeInput.vue'
import vuetify from "vuetify"
describe('ZipCodeInput', () => {
let wrapper;
beforeEach(()=>{
const localVue = createLocalVue()
localVue.use(vuetify)
wrapper = mount(ZipCodeInput, {
localVue,
})
})
it('Find input- type text ', ()=>{
expect(wrapper.contains('[data-test="zipCodeText"]')).toBe(true)
})
it('has a button', ()=>{
expect(wrapper.contains('button')).toBe(true)
})
})
The data-test
is a custom data attribute. It works as a custom selector instead of using the class, id, or tag.
Writing the code to pass these test
//ZipCodeInput.vue
<template>
<div>
<v-text-field
type="input"
single-line
placeholder="Enter US Zip code"
data-test="zipCodeText"
v-model="inputText"
></v-text-field>
<v-btn v-on:click="save" data-test="saveButton" color="primary">Enter</v-btn>
<br>
</div>
</template>
<script>
export default {
props: {
},
data () {
return {
inputText: "",
}
},
methods: {
}
}
</script>
Once we have added the elements to our component we can proceed to add the functionality. We can write the test cases for the following user stories of this component:
- User enter the text in the input (The test case should find the input box and enter the text. The value of the input binds with the data property
inputText
) - User clicks the ENTER button.
- Once the ENTER button is clicked, the input should be cleared.
- Check the emitted event
it('Enter text and check the value of inputText', ()=>{
var textInput = wrapper.find('[data-test="zipCodeText"]')
textInput.setValue('84102');
expect(wrapper.vm.inputText).toBe('84102')
})
it('click enter button and clear input', ()=>{
wrapper.find('button').trigger("click");
var textInput = wrapper.find('[data-test="zipCodeText"]')
expect(textInput.text()).toMatch('')
expect(wrapper.vm.inputText).toBe('')
})
it('Check emitted "save" event', ()=>{
wrapper.vm.$emit('save', "84102");
expect(wrapper.emitted().save).toBeTruthy()
})
vm
is the vue instance. You can access all the instance methods and properties of a vm with wrapper.vm
.
Create a save()
method in the ZipCodeInput.vue
to save the input and emit the custom event back to its parent.
save(){
this.$emit('save', this.inputText)
this.inputText = "";
}
Handling the emitted event
The event emitted from the ZipCodeInput.vue
should be handled in its parent component <Home>
inorder to process and get the data for the input provided.
Update Home.vue
<ZipCodeInput
v-on:save="processZipCode($event)"
/>
Testing the emitted custom event:
//Home.specs.js
it('test event emitted from ZipCodeInput', ()=>{
wrapper.find(ZipCodeInput).vm.$emit('save');
expect(wrapper.find(ZipCodeInput).emitted().save).toBeTruthy()
})
Add processZipCode(code)
in Home.vue
to handle the emitted event. This method will call a function to fetch the data from the API. Also add fahrenheitTemperature
and city
as data properties.
//Home.vue
...
<script>
import Model from './Model';
var model = new Model();
...
...
methods: {
processZipCode(code){
var data = model.fetchResponse(window.fetch, code);
data.then(res=>{
let kelvinTemp = res.main.temp;
this.FahrenheitConverter(kelvinTemp);
this.city = res.name;
})
},
},
data: () => ({
title: "Weather App",
fahrenheitTemperature: null,
city: "",
})
...
...
</script>
I am writing the function fetchResponse()
in a Model.js
file. This will be calling the API to fetch the data.
//Model.js
export default class Model {
fetchResponse(fetch, zipCode){
return fetch(`https://api.openweathermap.org/data/2.5/weather?zip=${zipCode},us&appid=YOURAPICODE`)
.then(response => response.json())
.then(data => data)
}
}
Testing asynchronous AJAX requests
There are two ways of writing test cases for asynchronous AJAX requests:
- Dependency injection
- Mocking modules
Both work in a similar way by creating fake modules to test the data. This would allow us to test AJAX requests faster and not make requests to the API for every test call. If we are making calls to API for every test case, it would make the process extremely slow.
For example, if we are using an API for payment system, we cannot make payments every time to test the API. Mocking the API requests helps to overcome this issue.
Using dependency injection:
I have injected fetch
as the dependency for the function fetchResponse()
. The Fetch API provides an interface for fetching resources.
Next, I have written a basic test to check if the API’s URL is called correctly by creating a fake implementation of the Fetch API response. In this test case, I have injected the dependency which is fakeFetch
.
//tests/unit/Model.spec.js
import Model from '@/components/Model';
var model = new Model();
it("calls fetch with correct url", ()=>{
const fakeFetch = url =>{
expect(url).toBe("https://api.openweathermap.org/data/2.5/weather?zip=84102,us&appid=YOURAPICODE")
return new Promise(function(resolve){
})
}
model.fetchResponse(fakeFetch, "84102")
})
Adding the dependency allows to make the code more testable. Without the dependency the test would not have any control over how fetchResponse()
calls fetch api. The fakeFetch
created here mimics the behavior of how the actual fetch
would respond.
Since we will not be calling the real API, we will also create a fake data which would be the resulting JSON response. You can get this data by actually calling the API.
//Model.spec.js
const fakeData = {
"coord": {
"lon": -111.89,
"lat": 40.77
},
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04n"
}
],
"base": "stations",
"main": {
"temp": 271.34,
"pressure": 1025,
"humidity": 84,
"temp_min": 270.45,
"temp_max": 272.15
},
"visibility": 11265,
"wind": {
"speed": 3.1,
"deg": 250
},
"clouds": {
"all": 90
},
"dt": 1547345700,
"sys": {
"type": 1,
"id": 5859,
"message": 0.0042,
"country": "US",
"sunrise": 1547391022,
"sunset": 1547425350
},
"id": 420037357,
"name": "Salt Lake City",
"cod": 200
}
//Test case
it("Check city name from the respponse", (done)=>{
const fakeFetch = url => {
return Promise.resolve({
json: () => Promise.resolve(fakeData)
})
}
model.fetchResponse(fakeFetch, "84102")
.then(result => {
expect(result.name).toBe("Salt Lake City")
})
done();
})
Ref: https://www.youtube.com/watch?v=0X1Ns2NRfks&t=1122s
Mocking modules
Lets assume that method processZipCode(zipcode)
calls model.requestResponse(zipcode)
instead of model.fetchResponse(fetch, zipcode)
//Model.js
import request from './request';
requestResponse(zipCode){
return request(zipCode);
}
//request.js
export default function request(zipCode){
return fetch(`https://api.openweathermap.org/data/2.5/weather?zip=${zipCode},us&appid=YOURAPICODE`)
.then(response => response.json())
.then(data => data)
}
Now in order to mock the function request()
, we need to create a __mocks__
directory in the same directory where the request.js
file is present. And then create a new request.js
file inside the newly created directory which will have the mocked function.
//__mocks__/request.js
export default function request(url){
return Promise.resolve(fakeData)
}
//fakeData is same as defined in the dependency injection example
Test the function requestResponse()
in model.spec.js
.
We also need to declare jest.mock()
, to let jest know about the mocked function.
//Model.spec.js
jest.mock('@/components/request');
it('test mock module', () => {
return model.requestResponse("84102")
.then(result => {
expect(result.name).toBe("Salt Lake City")
})
});
Ref: https://jestjs.io/docs/en/manual-mocks
We are using the following two properties from the response received. City name and the temperature, which are saved as the data property city
and fahrenheitTemperature
in the <Home>
component respectively.
However, the temperature received is in Kelvin and we need to convert it Fahrenheit.
Test for Kelvin- Fahrenheit conversion:
describe("Kelvin to Fahrenheit converter", function() {
it("should convert Kelvin temperature to Farenhit", function() {
expect(model.convertToFahrenhite(270)).toBe(26);
})
})
Code for Kelvin- Fahrenheit conversion:
convertToFahrenheit(kelvinTemp){
return (Math.round((kelvinTemp-273.15)*1.8)+32)
}
Creating DisplayTemperature.vue
Both the city name and Fahrenheit temperature are passed from <Home>
to its child component <DisplayTemperature>
.
//Home.vue
<DisplayTemperature
:city="city"
:fahrenheitTemperature="fahrenheitTemperature"
/>
`
- Test the props
- Test if the alert exists. (The temperature is displayed in an alert component. )
- Check the output
//tests/unit/DisplayTemperature.spec.js
import { createLocalVue, shallowMount } from '@vue/test-utils'
import DisplayTemperature from '@/components/DisplayTemperature.vue'
import vuetify from "vuetify"
describe('DisplayTemperature', () => {
let wrapper;
let city = "Salt Lake City"
let fahrenheitTemperature = 39
beforeEach(()=>{
const localVue = createLocalVue()
localVue.use(vuetify)
wrapper = shallowMount(DisplayTemperature, {
localVue,
propsData:{
city,
fahrenheitTemperature
}
})
})
it('has a alert element', ()=>{
expect(wrapper.contains('[data-test="alertElement"]')).toBe(true)
})
it('check props', ()=>{
expect(wrapper.vm.city).toBe('Salt Lake City')
})
it('check the output', ()=>{
expect(wrapper.text()).toMatch(`The current temperature in ${city} is ${fahrenheitTemperature} F.`)
})
});
Create the component and run the test cases again
//DisplayTemperature.vue
<template>
<div>
<v-alert data-test="alertElement" type="success" :value="true" outline>
The current temperature in is F.
</v-alert>
</div>
</template>
<script>
export default {
props: {
city: {
type: String
},
fahrenheitTemperature: {
type: Number
}
},
}
</script>
Running the test cases should show a similar result:
These are just some of the examples to get started with unit testing. Both vue-test-utils
and Jest
have documentation which is precise and clear to understand. Also the unit tests can serve as a type of documentation for the app. The it()
statements can describe exactly what each unit of the individual component is doing!
Summarizing the steps for writing tests:
- Import the component in the .spec.js file
- Import the required methods from vue-test-utils
- Import Vuetify
- Create a localVue instance with Vuetify.
- Mount the component
References:
vue-test-utils: https://vue-test-utils.vuejs.org/
Jest: https://jestjs.io/docs/en/getting-started
Test Driven Development: what it is, and what it is not
JavaScript Testing - Mocking Async Code