Knowing What To Test - Vue Component Unit Testing
Anthony Gore | August 26th, 2019 | 5 min read
The most common question about unit testing Vue components I see out there is "what exactly should I test?"
While it's possible to test either too much or too little, my observation is that developers will usually err on the side of testing too much. After all, no one wants to be the guy or girl who's under-tested component crashed the app in production.
In this article, I'll share with you some guidelines I use for unit testing components that ensure I don't spend forever writing tests but provide enough coverage to keep me out of trouble.
I'll assume you've already had an introduction to Jest and Vue Test Utils.
Example component
Before we get to the guidelines, let's first get familiar with the following example component that we'll be testing. It's called Item.vue and is a product item in an eCommerce app.
Here's the component's code. Note there are three dependencies: Vuex ($store
), Vue Router ($router
) and Vue Auth ($auth
).
Item.vue
<template>
<div>
<h2>{{ item.title }}</h2>
<button @click="addToCart">Add To Cart</button>
<img :src="item.image"/>
</div>
</template>
<script>
export default {
name: "Item",
props: [ "id" ],
computed: {
item () {
return this.$store.state.find(
item => item.id === this.id
);
}
},
methods: {
addToCart () {
if (this.$auth.check()) {
this.$store.commit("ADD_TO_CART", this.id);
} else {
this.$router.push({ name: "login" });
}
}
}
};
</script>
Spec file setup
Here's the spec file for the tests. In it, we'll shallow mount our components with Vue Test Utils, so I've imported that, as well as the Item component we're testing.
I've also created a factory function that will generate an overrideable config object, saving us having to specify props and mocking the three dependencies in each test.
item.spec.js
import { shallowMount } from "@vue/test-utils";
import Item from "@/components/Item";
function createConfig (overrides) {
const id = 1;
const mocks = {
// Vue Auth
$auth: {
check: () => false
},
// Vue Router
$router: {
push: () => {}
},
// Vuex
$store: {
state: [ { id } ],
commit: () => {}
}
};
const propsData = { id };
return Object.assign({ mocks, propsData }, overrides);
}
describe("Item.vue", () => {
// Tests go here
});
Identify the business logic
The first and most important question to ask about a component you want to test is "what is the business logic?", in other words, what is the component meant to do?
For Item.vue, here is the business logic:
- It will display an item based on the
id
prop received - If the user is a guest, clicking the Add to Cart button redirects them to the login page
- If the user is logged in, clicking the Add to Cart button will trigger a Vuex mutation
ADD_TO_CART
Identify the inputs and outputs
When you unit test a component, you treat it as a black box. Internal logic in methods, computed properties etc, only matter insofar as they affect output.
So, the next important thing is to identify the inputs and outputs of the component, as these will also be the inputs and outputs of your tests.
In the case of Item.vue, the inputs are:
id
prop- State from Vuex and Vue Auth
- User input via button clicks
While the outputs are:
- Rendered markup
- Data sent to Vuex mutation or Vue Router push
Some components may also have forms and events as inputs, and emit events as outputs.
Test 1: router called when guest clicks button
One piece of business logic is "If the user is a guest, clicking the Add to Cart button redirects them to the login page". Let's write a test for that.
We'll set up the test by shallow mounting the component, then finding and clicking the Add to Cart button.
test("router called when guest clicks button", () => {
const config = createConfig();
const wrapper = shallowMount(Item, config);
wrapper
.find("button")
.trigger("click");
// Assertion goes here
}
We'll add an assertion in a moment.
Don't go beyond the boundaries of the input and output
It'd be tempting in this test to check that the route changed to that of the login page after clicking the button e.g.
import router from "router";
test("router called when guest clicks button", () => {
...
// Wrong
const route = router.find(route => route.name === "login");
expect(wrapper.vm.$route.path).toBe(route.path);
}
While this does test the component output implicitly, it's relying on the router to work, which should not be the concern of this component.
It's better to directly test the output of this component, which is the call to $router.push
. Whether the router completes that operation is beyond the scope of this particular test.
So let's spy on the push
method of the router, and assert that it gets called with the login route object.
import router from "router";
test("router called when guest clicks button", () => {
...
jest.spyOn(config.mocks.$router, "push");
const route = router.find(route => route.name === "login");
expect(spy).toHaveBeenCalledWith(route);
}
Test 2: vuex called when auth user clicks button
Next, let's test the business logic for "If the user is logged in, clicking the Add to Cart button will trigger a Vuex mutation ADD_TO_CART
".
To re-iterate the above lesson, you don't need to check if the Vuex state gets modified. We would have a separate test for the Vuex store to verify that.
This component's job is simply to make the commit, so we just need to test it does it that.
So let's first override the $auth.check
mock so it returns true
(as it would for a logged-in user). We'll then spy on the commit
method of the store, and assert it was called after the button is clicked.
test("vuex called when auth user clicks button", () => {
const config = createConfig({
mocks: {
$auth: {
check: () => true
}
}
});
const spy = jest.spyOn(config.mocks.$store, "commit");
const wrapper = shallowMount(Item, config);
wrapper
.find("button")
.trigger("click");
expect(spy).toHaveBeenCalled();
}
Don't test functionality of other libraries
The Item component displays a store item's data, specifically the title and image. Maybe we should write a test to specifically check these? For example:
test("renders correctly", () => {
const wrapper = shallowMount(Item, createConfig());
// Wrong
expect(wrapper.find("h2").text()).toBe(item.title);
}
This is another unnecessary test as it's just testing Vue's ability to take in data from Vuex and interpolate it in the template. The Vue library already has tests for that mechanism so you should rely on that.
Test 3: renders correctly
But hang on, what if someone accidentally renames title
to name
and then forgets to update the interpolation? Isn't that something worth testing for?
Yes, but if you test every aspect of your templates like this, where do you stop?
The best way to test markup is to use a snapshot test to check the overall rendered output. This will cover not just the title interpolation, but also the image, the button text, any classes, etc.
test("renders correctly", () => {
const wrapper = shallowMount(Item, createConfig());
expect(wrapper).toMatchSnapshot();
});
Here are some examples of other things there is no need to test:
- If the
src
property is bound to the img element - If data added to the Vuex store is the same data that gets interpolated
- If the computed property returns the correct item
- If router push redirects to the correct page
Etc.
Wrap up
I think those three relatively simple tests are sufficient for this component.
A good mindset to have when unit testing components is to assume a test is unnecessary until proven otherwise.
Here are the questions you can ask yourself:
- Is this part of the business logic?
- Does this directly test the inputs and outputs of the component?
- Is this testing my code, or third-party code?
Happy testing!
About Anthony Gore
If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!
Click to load comments...