Vuejs and Vue Test Utils in Practice Part 2
Introduction
Unit testing is a crucial aspect of ensuring code quality, preventing regressions, and promoting discipline within the development team. In this article, which is part 2, we will explore the usage of Vue Test Utils (VTU), the official unit testing utility library for Vue.js applications. Built on top of JEST, VTU provides powerful tools for mounting and interacting with Vue components. As might remember from the article “Vuejs and Vue Test Utils in Practice Part 1" unit tests must be Fast, Reliable, and independent. This article encompasses various key topics, including:
- Testing usage of Vue directives and filters
- Testing Vue mixins
- Testing the usage of Vue mixins
- Testing Vue plugins
- Testing the usage of Plugins
- Testing API calls
- Testing form inputs and validation
- Using snapshot
- Testing the lifecycle hooks
Testing usage of Vue directives and filters
it follows a similar approach to testing Vue components. imaging we have a component that has a directive that assigns ‘Directive applied’ to the inputs that get this directive. we need first import the directive and create a Vue instance or component to test it. Apply the directive to an element using the Vue instance or component. Manipulate the element or trigger relevant events to test the directive’s behavior. Assert that the expected changes or effects occur.
<template>
<div>
<input v-my-directive />
</div>
</template>
<script>
export default {
directives: {
'my-directive': {
bind(el) {
el.value = 'Directive applied';
},
},
},
};
</script>
The unit test can be like this
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('applies the my-directive correctly', () => {
const wrapper = mount(MyComponent);
const input = wrapper.find('input');
expect(input.element.value).toBe('Directive applied');
});
});
For the filter imagine we have a filer called my-filter that can make words uppercase. the test can be like this.
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('applies the my-filter correctly', () => {
const wrapper = mount(MyComponent, {
data() {
return {
value: 'Filter applied',
};
},
});
const paragraph = wrapper.find('p');
expect(paragraph.text()).toBe('FILTER APPLIED');
});
});
Testing Vue mixins
we can create a test component that uses the mixin and assert the expected behavior. Ensure that the mixin’s methods, data properties, computed properties, and lifecycle hooks are properly tested. Mock or stub any external dependencies that the mixin relies on to isolate its behavior. we can also create a dummy component to test the behavior of the mixin separately.
// dummy function helper
function createDummyComponent() {
return Vue.extend({
template: '<div><button @click="handleClick">Click Me</button></div>',
methods: {
handleClick() {
// handle the button click event
}
}
});
}
// mixin.spec.js
import { shallowMount } from '@vue/test-utils';
import mixin from '@/mixins/mixin.js';
describe('Mixin', () => {
let DummyComponent;
beforeEach(() => {
DummyComponent = createDummyComponent();
DummyComponent.mixin(mixin);
});
it('increments the count when the button is clicked', () => {
const wrapper = shallowMount(DummyComponent);
expect(wrapper.vm.count).toBe(0);
wrapper.find('button').trigger('click');
expect(wrapper.vm.count).toBe(1);
});
});
Testing the usage of Vue mixins
In the following example, we have a Vue mixin called mixin.js
that provides a message
data property and a greet
method. The MyComponent
component imports and uses this mixin, displaying the greeting in its template.
// mixin.js
export default {
data() {
return {
message: 'Hello from the mixin!',
};
},
methods: {
greet() {
return this.message;
},
},
};
// MyComponent.vue
<template>
<div>
<p>{{ greeting }}</p>
</div>
</template>
<script>
import mixin from './mixin.js';
export default {
mixins: [mixin],
computed: {
greeting() {
return this.greet();
},
},
};
</script>
In the test file MyComponent.spec.js
, we mount the MyComponent
and write test cases to verify the behavior. The first test case checks if the greeting from the mixin is correctly displayed in the component. The second test case verifies that the greet
method from the mixin is called when accessing the greeting
computed property.
By using jest.spyOn
, we can spy on the mixin's method and assert whether it has been called.
// MyComponent.spec.js
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
import mixin from './mixin.js';
describe('MyComponent', () => {
it('displays the greeting from the mixin', () => {
const wrapper = mount(MyComponent);
expect(wrapper.find('p').text()).toBe(mixin.data().message);
});
it('calls the mixin method correctly', () => {
const wrapper = mount(MyComponent);
const greetSpy = jest.spyOn(mixin.methods, 'greet');
wrapper.vm.greeting;
expect(greetSpy).toHaveBeenCalled();
});
});
Testing Vue plugins
We have some plugins provided by the Vue community like Vue i18n, Vue Form Validation Libraries and etc, however, we are able to create our own plugin. So testing it typically involves testing its installation process and verifying that its functionality is accessible within the components. You can create a dummy component that uses the plugin and assert the expected behavior or features provided by the plugin. Additionally, test the plugin’s API methods or options, if applicable.
// myPlugin.js
export default {
install(Vue) {
Vue.prototype.$customMethod = () => {
return 'Hello, World!';
};
}
};
// plugin.spec.js
import { createLocalVue, shallowMount } from '@vue/test-utils';
import MyPlugin from '@/plugins/myPlugin.js';
function createDummyComponent() {
return {
template: '<div></div>'
};
}
describe('MyPlugin', () => {
it('accesses the custom plugin method', () => {
const localVue = createLocalVue();
localVue.use(MyPlugin);
const wrapper = shallowMount(createDummyComponent(), { localVue });
expect(wrapper.vm.$customMethod()).toBe('Hello, World!');
});
});
Testing the usage of Vue Plugins
Imagine we have our own i18n plugin like this and we utilize it in our component.
// plugins/i18n.js
export default {
install: (app, options) => {
// inject a globally available $translate() method
app.config.globalProperties.$translate = (key) => {
// retrieve a nested property in `options`
// using `key` as the path
return key.split('.').reduce((o, i) => {
if (o) return o[i]
}, options)
}
}
}
// MyComponent.vue
<h1>{{ $translate('greetings.hello') }}</h1>
As I mentioned before each unit test should be independent, so for testing it, we need to mock this plugin and test our component independently.
// MyComponent.spec.js
import { mount } from '@vue/test-utils'
import YourComponent from '@/components/YourComponent.vue'
describe('YourComponent', () => {
let mockTranslate
beforeEach(() => {
// Create a mock implementation of $translate
mockTranslate = jest.fn((key) => {
// Simulate translation based on the provided key
if (key === 'greetings.hello') {
return 'Hello, World!'
}
return ''
})
})
it('displays the translated text', () => {
const wrapper = mount(YourComponent, {
global: {
mocks: {
// Provide the mock implementation of $translate
$translate: mockTranslate
}
}
})
// Verify that the translated text is displayed correctly
expect(wrapper.find('h1').text()).toBe('Hello, World!')
})
it('calls $translate with the correct key', () => {
const wrapper = mount(YourComponent, {
global: {
mocks: {
$translate: mockTranslate
}
}
})
// Simulate a change in the translation key
wrapper.setProps({ translationKey: 'greetings.goodbye' })
// Verify that $translate was called with the correct key
expect(mockTranslate).toHaveBeenCalledWith('greetings.goodbye')
})
})
Testing API calls or promises
When testing asynchronous behavior like API calls or promises in Vue Test Utils, async
and await
keywords come in handy. It is important to test both resolve and reject situations. To achieve this, we can mock the axios.get
method. This replaces the original implementation of axios.get
with a mock function that returns a resolved Promise with a mock data object { data: 'mock data' }
in the success scenario, or a rejected promise with a mock error object in the error scenario.
To ensure a clean slate for each test, it’s important to clear all mock calls and instances before each test using jest.clearAllMocks()
.
After mounting the MyComponent
component, we can use await wrapper.vm.$nextTick()
. This statement ensures that any asynchronous behavior triggered by the component, such as API calls, rendering, or reactive updates, has been completed before proceeding with the test assertions.
In summary, by mocking API calls, using await
and await wrapper.vm.$nextTick()
, we can effectively test the behavior of components that rely on asynchronous operations like API calls.
Here is the code example illustrating these concepts:
import { mount } from '@vue/test-utils'
import MyComponent from './MyComponent.vue'
import axios from 'axios'
describe('MyComponent', () => {
beforeEach(() => {
jest.clearAllMocks(); // clears all mock calls and instances
});
it('displays data from API call', async () => {
// mocking axiosd.get
jest.mock('axios', () => ({
get: jest.fn(() => Promise.resolve({ data: 'mock data' }))
}))
const wrapper = mount(MyComponent)
await wrapper.vm.$nextTick()
expect(axios.get).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('mock data')
})
it('displays error message when API call fails', async () => {
jest.mock('axios', () => ({
get: jest.fn(() => Promise.reject(new Error('API Error')))
}));
const wrapper = mount(MyComponent);
// Wait for the component to render
await wrapper.vm.$nextTick();
// Verify that the error message is displayed
expect(wrapper.text()).toContain('API Error');
});
});
Another way to test API calls is by using the jest.fn()
and .mockReset()
function to create and reset the specific mock API.
Here’s the code example with the explanations:
let mockApi;
beforeEach(() => {
mockApi = jest.fn();
});
test('should handle a rejected API call', async () => {
mockApi.mockRejectedValue(new Error('API error'));
// ... rest of test code
});
test('should handle a successful API call', async () => {
mockApi.mockResolvedValue({ data: 'API response' });
// ... rest of test code
});
afterEach(() => {
mockApi.mockReset();
});
In the example, a mock API is defined using jest.fn()
and assigned to the mockApi
variable. Before each test, the mock API is reset using mockApi.mockReset()
to ensure a clean state for each test case.
In the first test case, the mock API is set to return a rejected promise using mockApi.mockRejectedValue(new Error('API error'))
. In the second test case, the mock API is set to return a resolved promise with a mock response object using mockApi.mockResolvedValue({ data: 'API response' })
.
By using .mockRejectedValue()
and .mockResolvedValue()
along with .mockReset()
, you can control the behavior of the mock API for different test cases and ensure reliable and predictable testing of API calls.
Testing form inputs and validation
Form input testing involves simulating user input and validating the expected output.
- Validation testing ensures that the form input meets the specified criteria, such as data type, length, and format.
- Testing for error messages involves triggering validation errors and checking that the correct error message is displayed.
- Testing for form submission involves simulating form submission and checking that the expected data is sent to the server.
- Testing for form reset involves resetting the form and checking that the input values are cleared.
- Testing for form disable/enable involves disabling or enabling the form and checking that user input is allowed or disallowed accordingly.
Here there is a simple example of testing a form that includes two fields(name and email) and a submit button. in this example, I am trying to assert the appearing error messages when the mandatory inputs are empty, check disable button when the form is triggered, and the state of the loading progress bar when the form is submitted.
import { shallowMount } from '@vue/test-utils'
import MyForm from './MyForm.vue'
describe('MyForm.vue', () => {
let wrapper
beforeEach(() => {
wrapper = mount(MyForm)
})
afterEach(() => {
wrapper.destroy()
})
it('displays error messages if fields are empty', () => {
const nameInput = wrapper.find('#name')
const emailInput = wrapper.find('#email')
const submitButton = wrapper.find('button[type="submit"]')
// Simulate form submission with empty fields
nameInput.setValue('')
emailInput.setValue('')
submitButton.trigger('click.prevent')
// Assert that error messages are displayed
expect(wrapper.find('.error').text()).toContain('Name is required')
expect(wrapper.find('.error').text()).toContain('Email is required')
})
it('submits form if fields are filled out', () => {
const nameInput = wrapper.find('#name')
const emailInput = wrapper.find('#email')
const submitButton = wrapper.find('button[type="submit"]')
// Simulate form submission with filled out fields
nameInput.setValue('John')
emailInput.setValue('john@example.com')
submitButton.trigger('click.prevent')
// Assert that form is submitted
expect(wrapper.emitted('submit-form')).toBeTruthy();
// Assert that form was reset
expect(nameInput.element.value).toBe('')
expect(emailInput.element.value).toBe('')
// we can also assert the form was reset with snapshot
expect(wrapper.html()).toMatchSnapshot()
})
it('disables submit button when form is submitting', async () => {
// Simulate form submission
const submitButton = wrapper.find('button[type="submit"]')
await submitButton.trigger('click')
// Assert that submit button is disabled
expect(submitButton.attributes('disabled')).toBe('disabled')
})
it('enables submit button when form submission is complete', async () => {
// Simulate form submission
const submitButton = wrapper.find('button[type="submit"]')
await submitButton.trigger('click')
// Simulate form submission complete
wrapper.vm.$data.isLoading = false
// Assert that submit button is enabled
expect(submitButton.attributes('disabled')).toBeUndefined()
})
})
Using snapshot testing to test component render output
Snapshot testing is a technique used to verify that the output of a component remains the same over time. It involves taking a snapshot of the rendered output of a component and comparing it to a stored snapshot. If the two snapshots are identical, the test passes. If they are different, the test fails and you need to manually inspect the changes to ensure they are intentional.
By using .except(wrapper.html().toMatchSnapshot())
Jest will compare the rendered output to the stored snapshot. If the output has changed, Jest will highlight the differences and prompt you to verify that the changes are intentional.
Snapshots vs test a small part of HTML (data-test-id will be explained in detail)
In most cases, we used the jest snapshot to create an HTML entity map for regression tests and ideally, we commit generated snapshots to the git repository. In the future, if any changes are made, the test will fail and the developer will be able to see the differences between the old HTML and the new one, and fix the problem or update the snapshot. Although the snapshot has many flaws, still based on the agreement within the team we use it instead of checking all parts of the UI in different test blocks.
But you might need to test a small part of the UI. So to make that part of HTML accessible we add the attribute “data-test-id” to the wrapper of the HTML. “test-id” is an attribute used to identify a DOM node for testing purposes.
<h1 data-test-id='header'>hello world</h1>
<button data-test-id='button'></button>
it('should set title of the page',()=>{
const wrapper=mount(yourComonentn);
wrapper.find("[test-id="button"]").trigger('click');
wrapper.find("[test-id="header"]").expect('hello world');
wrapper.destroy();
})
Testing the lifecycle hooks of a Vue component
There are several lifecycle hooks in Vue components, such as created, mounted, updated, and destroyed, that can be tested. we can use jest.spyOn to spy on the lifecycle hook methods and expect to assert that they are called as expected: this is an example:
describe('MyComponent', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(MyComponent);
});
afterEach(() => {
wrapper.destroy();
});
it('calls the created hook when the component is created', () => {
const createdSpy = jest.spyOn(MyComponent.options, 'created');
const wrapper = mount(MyComponent);
expect(createdSpy).toHaveBeenCalled();
});
it('calls the mounted hook when the component is mounted', () => {
const mountedSpy = jest.spyOn(MyComponent.options, 'mounted');
const wrapper = mount(MyComponent);
expect(mountedSpy).toHaveBeenCalled();
});
it('calls the updated hook when the component is updated', async () => {
const updatedSpy = jest.spyOn(MyComponent.options, 'updated');
const wrapper = mount(MyComponent);
wrapper.setData({ message: 'Updated message' });
await wrapper.vm.$nextTick();
expect(updatedSpy).toHaveBeenCalled();
});
it('calls the destroyed hook when the component is destroyed', () => {
const destroyedSpy = jest.spyOn(MyComponent.options, 'destroyed');
const wrapper = mount(MyComponent);
wrapper.destroy();
expect(destroyedSpy).toHaveBeenCalled();
});
});