Unit testing JavaScript Applications - Part 3 (Vue components)
June 09, 2020
We have talked about the basic unit testing concepts, why you should be writing unit tests, the approaches to writing unit testing, and we have also written some unit tests for Vuex getters, actions and mutations. In this post, we would be looking at writing unit tests for Vue.js components. Previously we described the things we need to consider for defining unit tests cases. The same steps apply for writing tests for UI components:
Define the inputs and outputs of the component based on its public interface
The inputs of Vue.js components include:
- user interaction (like a click, mouseover, keydown event)
- props provided to the component
- slots
- data from Vuex (via state and getters)
The output of Vue.js components include:
- the rendered HTML
- emitted events
- called Vuex methods (actions and mutations)
You might have noticed that this doesn’t include things like checking that a method on the Vue component was called, since those are only known to the Vue component and aren’t called by external parties. They are not part of the public interface of the component.
Consider the dependencies you need to mock
As much as possible, the inputs and outputs of the component are where your focus should be when writing unit tests for your component. However, the component could have dependencies like using some Vue instance prototype methods that were defined elsewhere, importing a module dependency to handle some operation, using globally registered sub components, etc.
Just like in other cases, the dependencies are not part of the functionality of the component you’re testing, so they should be mocked so you can focus on testing just your component. Knowing what to mock unfortunately requires knowledge about the internals of the component, which would mean that the test is more tightly coupled with the internal implementation of the component, which makes the tests less maintainable since any change to the internals of the component would always require an update to the unit tests as well. Luckily we would be using jest, so mocking dependencies would be easier.
Consider all the logical execution flows within the component
For a very robust solution, you should consider all the logical execution flows within your component, and create test cases to cover any of the missing flows. As mentioned before, the logical flows are usually created by if
, switch
, and other short circuiting operations within the code. For Vue.js components, these can be created in the JS logic of the component, as well as in the component template using the v-if
directive and other conditional rendering directives.
Testing Tools
As we have already seen, we use Jest testing framework for writing and running our unit tests. However Jest on its own can only carry us so far. UI components require extra specialized testing tools to be able to easily write tests for them. For Vue.js components, we use the Vue test utils which is maintained by the Vue.js team. It provides us with the ability to mount the Vue.js component, simulate user interactions, easily mock global plugins and mixins as well as mocking sub components, props, data, methods, etc. It also enables us to be able to check the rendered HTML as well as checking that certain events were emitted. We would use it within the Jest test cases.
For a simple example, consider the following Vue component. It has a button that increments the counter, and displays the counter value.
// counter.js
export default {
template: `
<div>
<span class="count">{{ count }}</span>
<button @click="increment">Increment</button>
</div>
`,
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
}
}
To test the component, you need to “mount” the component, creating a vue test utils wrapper which gives you several methods for manipulating, traversing and querying the underlying Vue component instance.
import { mount } from '@vue/test-utils';
import Counter from './counter';
it('should have a button', () => {
const wrapper = mount(Counter);
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('button').html()).toBeTruthy();
});
We mount the Counter
component, creating an instance of the component wrapped within a wrapper. From the wrapper object, we can use the convenience methods to check if the button element exists and was rendered.
The wrapper has several methods that could be useful. You should check out all the available methods to see what is available.
mount vs shallowMount
Vue test utils comes with two methods for mounting components: mount
and shallowMount
. mount
instantiates the component along with all its sub components initialized as well, building out the whole DOM tree. shallowMount
on the other hand instantiates the component but stubs out the sub components, replacing them with stubs. This ensures that its just the current component that is executed during the test. This aligns with the idea of mocking dependencies instead of executing the real dependencies. This makes the tests run faster, and also keeps the tests isolated, which is something we have established that you need when running unit tests.
Let’s write some tests!
Again, we will be using the rick and morty web app as the case study and we would be writing unit tests for the components.
The app is a simple one with a few views and components: CharacterCard, Pagination, and Header. We would be testing the CharacterCard component here.
The CharacterCard component simply displays the basic details about a character in a card form. So as you would expect, it takes as input a character
prop. It also emits the click
event when the card is clicked.
<template>
<div
class="max-w-sm w-full lg:max-w-full lg:flex shadow-md hover:shadow-xl transition duration-300 ease-in transform hover:-translate-y-px rounded cursor-pointer"
@click="onClickCard"
>
<div
class="h-48 lg:h-auto lg:w-48 flex-none bg-cover rounded-t lg:rounded-t-none lg:rounded-l text-center overflow-hidden"
:style="`background-image: url('${character.image}')`"
:title="character.name"
></div>
<div
class="bg-white rounded-b lg:rounded-b-none lg:rounded-r p-4 flex flex-col flex-grow justify-between leading-normal text-left"
>
<div class="mb-8">
<div class="text-gray-900 font-bold text-xl mb-2">
{{ character.name }}
<div class="text-sm text-gray-600">
{{ character.gender }} ·
<span
tid="character-card-status"
:class="statusClasses"
>{{ character.status }}</span
>
· {{ character.origin.name }}
</div>
</div>
<p class="text-sm text-gray-600 flex items-center">
<img src="../assets/planet.png" class="w-5 mr-1" />
{{ character.location.name }}
</p>
<div class="mb-3"></div>
<span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2"
>{{ character.species }}</span
>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'CharacterCard',
props: {
character: {
type: Object,
required: true,
},
},
computed: {
statusClasses(): unknown {
return {
'ml-1': true,
'text-green-600': this.character.status === 'Alive',
'text-red-600': this.character.status !== 'Alive',
};
},
},
methods: {
onClickCard() {
this.$emit('click', this.character);
},
},
});
</script>
Like we have previously discussed about defining the test cases:
- We start by identifying the inputs and outputs of the component. The inputs here include: the
character
props, and the user click action. The outputs would be: the rendered component (with all the expected data), and the emittedclick
event, after the user clicks the card. - Next, we check the dependencies of the component. In this case, there isn’t any dependencies, so we skip this.
- Finally, we check all logical execution flows. There’s one conditional in the
statusClasses()
computed properties, although it’s nt very obvious. When the character status is “Alive”, we set thegreen
class, else we set thered
class. This is one of those conditions that isn’t very obvious at first glance. A good way to find these is to check for the conditional expressions (equalities===
!==
, greater than>=
, less than<=
, negation!
, etc).
From this, we can see that the test cases required are not much.
import CharacterCard from './CharacterCard.vue';
import { shallowMount } from '@vue/test-utils';
describe('CharacterCard', () => {
it('should render the character basic details', () => {
const character = {
name: 'Character',
image: 'character.jpg',
gender: 'Gender',
status: 'Alive',
species: 'Human',
origin: {
name: 'Origin',
},
location: {
name: 'Location',
},
};
const comp = shallowMount(CharacterCard, {
propsData: {
character,
},
});
expect(comp.html()).toContain(character.name);
expect(comp.html()).toContain(character.image);
expect(comp.html()).toContain(character.gender);
expect(comp.html()).toContain(character.status);
expect(comp.html()).toContain(character.species);
expect(comp.html()).toContain(character.origin.name);
expect(comp.html()).toContain(character.location.name);
});
it('should render status as green if alive', () => {
const character = {
// ...
};
const comp = shallowMount(CharacterCard, {
propsData: {
character,
},
});
expect(
comp
.find('[tid="character-card-status"]')
.classes('text-green-600')
).toBe(true);
});
it('should render status as red if dead', () => {
const character = {
// ...
};
const comp = shallowMount(CharacterCard, {
propsData: {
character,
},
});
expect(
comp
.find('[tid="character-card-status"]')
.classes('text-red-600')
).toBe(true);
});
it('should emit click with the character when clicked', () => {
const character = {
// ...
};
const comp = shallowMount(CharacterCard, {
propsData: {
character,
},
});
comp.trigger('click');
expect(comp.emitted('click').length).toBe(1);
expect(comp.emitted('click')[0]).toEqual([character]);
});
});
First we check that the basic character info are rendered in the output. The next test case checks that the green
class is rendered when character.status
is “Alive”. After that we check the else clause: that the red
class is rendered instead. Note the tid
attribute added to the status element. This makes it easy to select that element in the test. Finally, we check that the click event is emitted when the user clicks the character card.
Snapshot testing
As you might have noticed in the first test case, to verify that the character info is rendered as we expect, we needed to add a lot of expect
assertions for the different data points. This is already a lot of assertions for a simple test case. You can imagine how many more expect
assertions would be required for more complex components. This makes the tests less maintainable, as changes made could require you to update several of the assertions. One way to solve this kinds of test cases is with snapshot testing.
Snapshot testing basically allows us to render our component and save the rendered output to disk (as a snapshot of the component), and use this rendered output as a reference when running future tests. If the rendered output during a test is different from the original snapshot, the test would fail. You can then compare the two snapshots to see the difference, and then determine if the new rendered output is valid or not, and update the snapshot.
One of the limitations that appear when using snapshot is how you manage dynamic content. For example, assuming a component renders the current date, each time the snapshot test is run, the snapshots would be different. The recommended way to go around this is by mocking the dependencies (in this case, the Date
object) as we have previously discussed.
If we re-write the first test case using snapshot testing, we would have this:
it('should render the character basic details', () => {
const character = {
name: 'Character',
image: 'character.jpg',
gender: 'Gender',
status: 'Alive',
species: 'Human',
origin: {
name: 'Origin',
},
location: {
name: 'Location',
},
};
const comp = shallowMount(CharacterCard, {
propsData: {
character,
},
});
expect(comp.html()).toMatchSnapshot();
});
We replaced all the assertions with a single expect(comp.html()).toMatchSnapshot()
. This is much cleaner, and easier to maintain. Generally when testing the rendered component output, snapshot testing makes for a cleaner testing approach.
The snapshot looks like this:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CharacterCard should render the character basic details 1`] = `
<div class="max-w-sm w-full lg:max-w-full lg:flex shadow-md hover:shadow-xl transition duration-300 ease-in transform hover:-translate-y-px rounded cursor-pointer">
<div title="Character" class="h-48 lg:h-auto lg:w-48 flex-none bg-cover rounded-t lg:rounded-t-none lg:rounded-l text-center overflow-hidden" style="background-image: url(character.jpg);"></div>
<div class="bg-white rounded-b lg:rounded-b-none lg:rounded-r p-4 flex flex-col flex-grow justify-between leading-normal text-left">
<div class="mb-8">
<div class="text-gray-900 font-bold text-xl mb-2">
Character
<div class="text-sm text-gray-600">
Gender ·
<span tid="character-card-status" class="ml-1 text-green-600">Alive</span>
· Origin
</div>
</div>
<p class="text-sm text-gray-600 flex items-center"><img src="../assets/planet.png" class="w-5 mr-1">
Location
</p>
<div class="mb-3"></div> <span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2">Human</span>
</div>
</div>
</div>
`;
Note: You would need the jest-serializer-vue snapshot serializer to generate useful snapshots for your Vue components.
Let’s consider writing a test for the Header component. It just displays the logo and a list of links used for navigating between pages.
<template>
<nav
class="flex items-center justify-between flex-wrap bg-purple-900 p-6"
>
<!-- ... -->
<div
class="w-full block flex-grow lg:flex lg:items-center lg:w-auto"
>
<div class="text-md lg:flex-grow">
<router-link
class="block mt-4 lg:inline-block lg:mt-0 text-purple-200 rounded-full lg:hover:bg-purple-800 lg:px-3 py-1 hover:text-white mr-4 transition duration-300"
to="/"
>
Home
</router-link>
<router-link
class="block mt-4 lg:inline-block lg:mt-0 text-purple-200 rounded-full lg:hover:bg-purple-800 lg:px-3 py-1 hover:text-white mr-4 transition duration-300"
to="/about"
>
About
</router-link>
<router-link
class="block mt-4 lg:inline-block lg:mt-0 text-purple-200 rounded-full lg:hover:bg-purple-800 lg:px-3 py-1 hover:text-white mr-4 transition duration-300"
to="/characters"
>
Characters
</router-link>
</div>
</div>
</nav>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'Header',
});
</script>
We can define the test cases as follows:
- inputs and outputs: it doesn’t really have any inputs. Although it contains
router-link
components that the user’s can click, the click interaction occurs on therouter-link
component, not theHeader
component. For the output, we only have the rendered component output. - mocking dependencies: it contains
router-link
components for navigating the user between pages.router-link
component is globally registered, and is not registered locally within the Header component, so usingshallowMount
would not stub the component. We would need to explicitly stub it out ourselves. - logical execution flows: it doesn’t have any conditional statements or expressions in the templates or scripts section, so we can skip this.
import Header from './Header.vue';
import { shallowMount } from '@vue/test-utils';
describe('Header', () => {
it('should render', () => {
const comp = shallowMount(Header, {
stubs: {
RouterLink: true,
},
});
expect(comp.html()).toMatchSnapshot();
});
});
We have a single test case that asserts the rendered output. Given what we know about testing rendered output, we just use a expect(comp.html()).toMatchSnapshot()
.
Like we mentioned, the router-link
component is not registered locally within the Header
component. So shallowMount
wouldn’t know to create a stub for it. Fortunately, we can explicitly indicate to shallowMount
that it should stub the router-link
component. Note that we can use either RouterLink: true
or 'router-link': true
. Vue uses both formats for defining component names. Setting RouterLink
to true tells shallowMount
to create a simple dummy stub. If you check the generated snapshot, you’d notice that the router-link
component is now routerlink-stub
.
You can also pass a component to be used as the stub instead, if you’d like.
const routerStub = {
template: '<div></div>'
};
const comp = shallowMount(Header, {
stubs: {
RouterLink: routerStub,
},
});
Testing with subcomponents that use slots
Sometimes you would have sub components that use slots to specify where specific contents would be rendered.
<template>
<div>
<header>My wonderful component</header>
<MySelect>
<template slot="label">My select label</template>
<template slot="error">My select error message</template>
</MySelect>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MySelect from './MySelect.vue';
export default Vue.extend({
name: 'MyWonderfulComponent',
components: {
MySelect,
}
});
</script>
Here the MySelect
component has a label and error slots, which we pass values to. If we write a unit test for MyWonderfulComponent
component using shallowMount
, you would notice that the rendered output doesn’t contain the text for the label and error slots, because the MySelect
component would have been stubbed with a dummy stub component which doesn’t use slots. To work around this, we can provide our own stub component to be used to replace MySelect
component in a deterministic way, and use that during the test rather than the real MySelect
component.
const mySelectStub = {
template: `<div>
<div class="label-slot">
<slot name="label"></slot>
</div>
<div class="error-slot">
<slot name="error"></slot>
</div>
<div class="default-slot">
<slot />
</div>
</div>`
};
const comp = shallowMount(Header, {
stubs: {
MySelect: mySelectStub,
},
});
With this, we would have the slots rendered, and we can check for that easily.
Conclusion
One final note about dependencies is that: not all dependencies are equal. In the previous article, we asked the question about whether Vue.set()
used within mutations should be mocked when writing unit tests. To determine if a dependency should be mocked, these are a couple of questions that can help you decide:
- is it a pure function dependency? This means it doesn’t have an effect on any thing besides returning an output for the input you give it.
- if it has side effects, are they local to the piece of code being tested? For example,
Vue.set()
only mutates the given input, and does nothing else. - is its output dependent on external factors? This affects the deterministic nature of the dependency. For example,
Date.now()
would give a different result based on the current time. - is it difficult to mock the dependency? Does mocking dependency require a whole lot more work (like A LOT MORE) than actually writing the test?
Usually this question comes up when dealing with implicit dependencies, because they make mocking dependencies hard.
Over the course of this series, we have looked at unit testing approaches, and how to write unit tests for different forms of code. We looked at how to mock dependencies as well as how to make writing tests easier. We considered the TDD approach and saw how we can write our code following that pattern. We have also looked at various testing tools like Jest and the Vue test utils for a robust unit testing solution.
While it is recommended to follow the TDD approach as much as possible due to the benefits you get, it is not a mandatory practice for writing unit tests. Following the testing approach for defining your test cases, you can have full test coverage of your code, even if you write the tests afterwards.
Writing unit tests doesn’t have to be hard. Following a defined approach makes it easier.
Did you find this useful? Do you think there are better approaches to take, or questions? You can reach out to me on twitter @imolorhe.
Write about what you learn. It pushes you to understand topics better.
Sometimes the gaps in your knowledge only become clear when you try explaining things to others. It's OK if no one reads what you write. You get a lot out of just doing it for you.
@addyosmani