Building Quick and Clean Test Suites in Vue.js

E Ozcelik
The Startup
Published in
7 min readNov 14, 2020
Clean Testing in VueJS — logo

If the title of this article attracted your attention, I am certain that, as a front-end or full-stack developer, you are quite familiar with the feeling of being overwhelmed by all the minute details of building out front-end unit tests with vue-test-utils. Either this feeling makes you avoid writing tests all together or each time you write a new test suite, you need to rerun the simplest test case 50 times just to set up the test file correctly with necessary mocks and spies.

That was my sentiment up until recently when I decided to adopt the TDD approach on my next big project. With TDD requiring that you write the tests before even creating the related component and its methods, I had no choice but to come up with efficient techniques. What I am going to present below is the accumulation of these techniques that proved themselves to be worthy after a long process of trial-and-error.

TL:DR

Test folder structure should closely follow the source code structure

Extract and centralize mocks and Avoid magic values in your tests

Use a wrapper factory

Simplify your factory by extracting mocked values to a separate file

Let’s start slow and easy. Here is my first recommendation:

Test folder structure should closely follow the source code structure

  • You may not notice this during the early stages of writing test files but later on, as you add more features to an existing component, you will want to add corresponding tests or maintain broken tests related to that file. For quick access to the tests of a given component, you should be able to navigate your tests folder in the same way as you navigate your source folder. With this technique, you can also search your project by filename and start typing the name of the component and see the ‘.test.js’ version located in the correct path.

Let’s say my src folder looks like this:

src
|components -> auth -> Auth.vue
|store -> modules -> user.js

My test folder should more or less look like this:

test
|components -> auth -> auth.test.js
|store-> modules -> user.test.js

Finding the related test file instantly with file search

Extract and centralize mocks and Avoid magic values in your tests

  • Writing unit tests means preparing a lot of mocks to set up your tests in a predictable way. However, often, we realize that some mocks used in some other file are needed to newer test suites. Or you may not even realize this even though it might be the case because maybe some other person wrote that mock and you are not aware of it. Quickly your tests are riddled with countless lines of mock data. Yet, in my opinion, test files should read like an instruction book from start to end (Given A, When you do B, Then C happens). Extracting these mock data to a known location helps with both of these points. I usually create a folder called __mocks__ inside test folder and import mocked data into my tests from there. While writing a new test, if you need mock data, you will be able to go in this directory and search for some keywords to see if similar data exists or not. And your test file will only contain the core of the test in a readable manner.
  • You should also avoid testing for magic values based on these mock data. Let me give an example of what I am talking about :

Imagine that we are going to test the method addToQueue(newElement) and we want to make sure that component’s computed property latestItemId becomes the id of the newElement. We could simply do the following :

const newTestElement = {id: 5, name: 'element name'};
wrapper.vm.addToQueue(newTestElement);
expect(wrapper.vm.latestItemId).toBe(5);

However, with this approach, it becomes way too easy to make a mistake in the future since the test conditions are disconnected from the expected values. If I happen to refactor the declaration of newTestElement, I need to manually check each dependency through the test suite. Beyond this basic example, this can get quite tricky. Below, you can find how I remove this complication from my tests.

const newTestElement = {id: 5, name: 'element name'};
wrapper.vm.addToQueue(newTestElement);
expect(wrapper.vm.latestItemId).toBe(newtestElement.id);

Use a wrapper factory

  • Now, we have come to the real meat of the article. Generate your test components (wrappers) through a centralized and standardized factory instead of painstakingly building test components for each test case.

Let’s take into account the basic test example presented in the official vue-test-utils documentation :

import { createLocalVue, shallowMount } from '@vue/test-utils'
import MyPlugin from 'my-plugin'
import Foo from './Foo.vue'

const localVue = createLocalVue()
localVue.use(MyPlugin)
const wrapper = shallowMount(Foo, {
localVue,
mocks: { foo: true }
})
expect(wrapper.vm.foo).toBe(true)

const freshWrapper = shallowMount(Foo)
expect(freshWrapper.vm.foo).toBe(false)
  • In keeping with the unit testing principal that each test should be independent and start with a clean slate, the wrapper is configured for each test. In a simple example such as this, this doesn’t seem to be a bother but, as your components get more and more complex, this testing style becomes really hard to manage and almost completely unreadable since ten lines of actual test code might be lost among hundreds of lines of test set-up code. The worst part is the fact that during each set-up, you will be setting up the same mocks, same props, same stubs and so on (especially for shared store and service calls), even between different components.
  • This is why I created a dedicated file that handles wrapper creation and that exposes two functions: componentFactory and defaultComponentConfiguration.

— componentFactory is a function that takes the component to mount and mounting options and then returns a mounted test component.

export const componentFactory = (component, componentConfiguration) => {
const localVue = createLocalVue();
localVue.use(Vuex);

for (const [key, value] of Object.entries(componentConfiguration.prototypes)) {
localVue.prototype[key] = value;
}

const configuration = {
localVue,
store: new Vuex.Store(componentConfiguration.store),
mocks: componentConfiguration.mocks,
stubs: componentConfiguration.stubs,
propsData: componentConfiguration.propsData
}

return shallowMount(component, configuration);
}

— defaultComponentConfiguration returns simply an object that represents the most generic starting point for all test wrappers. This corresponds to the second argument passed to componentFactory function above. It allows us to build a test component within a matter of seconds with default options or selectively modify the configuration according to our needs (for example, setting up props data or injecting spies. I will show how to do this in a second).

export const defaultComponentConfiguration = () => {     return {
store: {
modules: {
...
user: {
namespaced: true,
state: {
...
},
getters: {
...
},
actions: {
...
}
}
}
},
mocks: {
$t: jest.fn().mockReturnValue('string'),
$emit: jest.fn(),
...
},
stubs: ['router-link', 'router-view'],
propsData: {},
prototypes: {
$CONSTANTS: {
...
},
...
}
}
};
  • Now let’s see how this all comes together.

— Testing a basic component with no customization needed :

import { componentFactory, defaultComponentConfiguration } from '../../__mocks__/componentFactory’;
import Foo from '@/modules/myModule/components/Foo’;

describe(’Foo’, () => {
it(’is a Vue instance’, () => {
const wrapper = componentFactory(Foo, defaultComponentConfiguration());
expect(wrapper).toBeTruthy();
});
});

No matter the complexity of those default configurations, my test suite is as clean and readable as it gets.

— Testing a component with additional props data :

import { componentFactory, defaultComponentConfiguration } from '../../__mocks__/componentFactory’;
import Foo from '@/modules/myModule/components/Foo’;
const customComponentConfiguration = defaultComponentConfiguration();const generateFooComponent () {
return componentFactory(Foo, customComponentConfiguration);
}
describe(’Foo’, () => {
beforeEach(() => {
customComponentConfiguration.propsData.isEmpty = false;
});
it(’is a Vue instance’, () => {
const wrapper = generateFooComponent();
expect(wrapper).toBeTruthy();
});
});

In order to customize our complex set up configuration, we only need to do the following :

1- Extract a copy of default configurations as customComponentConfiguration
2- Create a reusable component generation function with generateFooComponent()
3- Modify the custom configs as needed before calling generateFooComponent()

— Testing a component by injecting spies

import { componentFactory, defaultComponentConfiguration } from '../../__mocks__/componentFactory’;
import Foo from '@/modules/myModule/components/Foo’;
const customComponentConfiguration = defaultComponentConfiguration();const generateFooComponent () {
return componentFactory(Foo, customComponentConfiguration);
}
describe(’Foo’, () => {
const checkIfLoggedInSpy = jest.fn();
beforeEach(() => {
customComponentConfiguration.store.modules.user.actions.checkIfLoggedIn = checkIfLoggedInSpy;
});
it(’dispatches checkIfLoggedIn action from user store when it is mounted’, () => {
const wrapper = generateFooComponent();
expect(checkIfLoggedInSpy).toHaveBeenCalledTimes(1);
});
});

Simplify your factory by extracting mocked values to a separate file

  • As a final point, I would like to point out that during the creation of default component configurations, those parts that I marked with an ellipsis (such as the contents of store modules and plugins) can get quite long, making the configuration file huge. I recommend extracting those mock values to a separate file so you can see your whole test configuration more clearly. When the time comes and you need to add a new getter to one of your store modules, you don’t need to find the correct place in the parenthesis hell. You can just go to the relevant file for that. And your component configuration will always stay simple as follows:
export const defaultComponentConfiguration = () => {return {
store: {
modules: {
...
user: {
namespaced: true,
state: userStateSpies,
getters: userGettersSpies,
actions: userActionsSpies
}
}
}
}
...
};

--

--