Unit Testing Vue Components

by Brian Simon ()

There are a lot of articles on the web that go over the mechanics of using your test runner such as Jest or Vitest, and the mechanics of using the @vue/test-utils package. I won’t go into any of that here.

Instead of going over how to write tests, we’ll explore how to think about which tests to write, and what to assert in each test. Our goal in this methodology is to be able to write resilient tests that allow us freedom in refactoring without needing to spend time updating brittle tests.

Fundamentals

Some of the assumptions we’ll make about most Vue components are:

  1. They behave in a predictable way
  2. They have inputs and outputs

Now, what do we mean when we say “they behave in a predictable way”? Simply put, most components do not use randomness in their behavior. Following the same inputs, we can consistently achieve the same outputs.

In the context of a Vue component, an input is anywhwere data flows into the component:

  1. Component Prop
  2. User interaction (such as a button click)
  3. Result of an API call
  4. Global state (e.g. from Vuex or Pinia)

An output is anywhere data flows out of a component:

  1. A prop passed to a child
  2. DOM / <template> structure
  3. Global state mutation
  4. API call

Which tests to write?

One mistake I see all the time that makes for very brittle unit tests is a focus on testing things other than the inputs and outputs.

Consider the following component:

vue
<template>
<div>
The count is {{ count }}.
<button @click="buttonClicked">
+1
</button>
</div>
</template>
<script>
export default {
data: () => ({
count: 0,
}),
methods: {
buttonClicked() {
this.count++;
}
}
}
</script>

It has a button, and when we click the button, this buttonClicked method is called which increments a count variable.

What NOT to do

js
let wrapper;
beforeEach(() => {
wrapper = shallowMount(MyCounter);
});
it('should call the buttonClicked method when the button is clicked', () => {
const spy = jest.spyOn(wrapper.vm.buttonClicked);
await wrapper.find('button').trigger('click');
expect(spy).toHaveBeenCalled();
});
it('should update the count variable when the buttonClicked method is called', () => {
expect(wrapper.vm.count).toBe(0);
wrapper.vm.buttonClicked();
expect(wrapper.vm.count).toBe(1);
});

These are examples of brittle tests. They will break if we refactor any part of the component, and will force us to spend time and energy fixing them when we change anything.

These tests lock us in to this specific implementation of our component. It relies on there being a count variable and buttonClicked method. We cannot rename our count variable now without needing to update a unit test. We cannot rename our buttonClicked method without needing to update a unit test.

In general, the presence of wrapper.vm at all in a unit test means that we’re not focusing on our inputs and outputs, but instead on implementation details. We should never focus on implementation details in a component test.

Do this instead:

Instead of relying on the component being implemented a specific way, focus on what it does. How it works is irrelevant - we only want to test that for X input, Y output happens.

That’s how I always advise people to approach writing UI component tests: focus only on inputs and outputs.

  • What are the inputs?
  • What are the outputs?
  • Which inputs affect which outputs?

Answer those three questions and you’ll have a very good set of test cases to start with.

In the above example, our input is user interaction on the button click. We know that the expected behavior when the button is clicked is that the count displayed in the label is incremented by 1.

We don’t care how that count label is incremented. It could be a Vue ref where Vue updates the label, it could be that our component accesses the DOM API directly to update the element’s innerText, it could be that it takes 1 function call or 10 function calls or 100 function calls to update that label. When focusing on behavioral tests, none of that matters. All that matters in the end is that it gets updated, so that’s what we’ll assert in our tests.

Answering those three questions here:

  1. What are the inputs?
    • a button click
  2. What are the outputs?
    • the count label in the template
  3. Which inputs affect which outputs?
    • a button click increments the count label

We can distill that down into the following test cases:

  • it should display “The count is 0.” when the component is first mounted
  • it should update the count when the button is clicked
js
let wrapper;
beforeEach(() => {
wrapper = shallowMount(MyCounter);
});
it('should display "The count is 0." when first mounted', () => {
expect(wrapper.text()).toMatch('The count is 0');
});
it('should update the count when the button is clicked', async () => {
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toMatch('count is 1');
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toMatch('count is 2');
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toMatch('count is 3');
});

Using these test cases, we now have the freedom to refactor our component without needing to spend time and brain cycles updating unit tests. We can implement our component any number of ways, but as long as it exhibits this behavior, our tests will be happy.