Jest tests best practices with VueJs and Vuex
08 Sep 2024Introduction
When thinking about best practices, here are the 2 rules I’m trying to live for:
- “Keep things simple”. This encourages people to write tests. If a test starts being too complicated, think twice about it and find ways to simplify things whenever possible.
- “Get it working and don’t worry too much about the code design”. You can always polish the code for your tests, but it seems more important to spend time adding more tests to improve the code coverage, than polishing tests.
Run with --coverage
Running the jest tests with --coverage
is always a good idea since it allows to find out what lines are and what lines are not covered by jest tests. We can then add or update the tests accordingly.
yarn test myFile.spec.js --coverage
Run the coverage only for the file you are testing
The above command may be found impractical since we get a huge table that includes coverage for untested files, making it complicated to find the one we are interested about.
To work around that we can target the source files that the coverage should include. In the example below, we are executing test topicForm.spec.js
and targeting the appropriate Vuex module.
yarn test \
--coverage \
--collectCoverageFrom=resources/assets/js/store/modules/topicForm.js \
topicForm.spec.js
💡 Right click on the file in VSCode and click on “Copy Relative Path” to easily get a value for the --collectCoverageFrom
option.
We can also use wildcards in the path. See the example that follows.
Run the coverage for multiple files at once
Something we are often looking for is to check the coverage for a group of files.
The option --testPathPattern
can be useful in that case.
yarn test \
--coverage \
--testPathPattern topicForm \
--collectCoverageFrom="resources/**/*opicForm*"
💡 Note that the path for the --collectCoverageFrom
option is case sensitive, which is why in the example we used the string *opicForm*
. This is useful if we need to match files for both topicForm
and TopicForm
.
Testing Vuex modules
Get the initial state from the module using “module.state()”
This can help catch issues if the initial state gets updated and has an impact on some part of the application.
import { createStore } from 'vuex';
import topicListingModule from '~/js/store/modules/topicListing.js';
...
return createStore({
modules: {
topicListing: {
...topicListingModule,
state: {
// Only useful if other state properties are overridden
...topicListingModule.state(),
},
},
},
});
If needed, use a factory to override the initial state
When you need a specific state for your test, override the initial one.
...
const getStore = (overriddenState = {}) => {
return createStore({
modules: {
topicListing: {
...topicListingModule,
state: {
...topicListingModule.state(),
...overriddenState,
},
},
},
});
};
Don’t namespace modules in tests to improve readability
When unit testing a single module, you can override the namespaced
property to disabled name spacing.
return createStore({
modules: {
topicListing: {
...topicListingModule,
namespaced: false,
},
},
});
This will simplify the store method calls, resulting in:
await store.dispatch('getTopics');
Instead of:
await store.dispatch('topicListing/getTopics');
Prefer testing code that’s actually being used outside of Vuex modules
If a mutation is only called by an action within the module, testing the action will cover for it. You don’t necessarily have to test the mutation directly. The idea is to keep the tests as simple as possible to write, which will also make them as simple as possible to maintain.
Don’t mock mutations or actions to assert they are called, but test against the state.
Vuex is all about having the state being the source of truth. What we want to assert is that the state is in the right “state” after calling actions or mutations. Anything else will distract us from what is important and make the tests harder to read and maintain.
// Do not
const commit = jest.fn()....
store.actions.addTopic({ commit }, {
id: 'tc_1',
name: 'test'
});
expect(commit).toHaveBeenCalledWith('ADD_TOPIC', 'test');
// Do
expect(topicState.topics).toEqual([]);
store.dispatch('addTopic', {
id: 'tc_1',
name: 'test'
});
expect(topicState.topics).toEqual([{
id: 'tc_1',
name: 'test'
}]);
When mocking async call (api calls), test valid data, but also test errors.
Example with successful call:
import TopicApi from '~/dashboard/js/api/topics';
const store = createStore(....);
const topicsState = store.state.topicListing;
TopicApi.list = jest.fn(() => {
return {
data: {
data: [
{ id: 'topic_1' },
{ id: 'topic_2' },
]
},
};
});
await store.dispatch('getTopics');
expect(topicsState.topics).toEqual([
{ id: 'topic_1' },
{ id: 'topic_2' },
]);
Example with an error that would be caused by an HTTP response with a 500 status code:
import TopicApi from '~/dashboard/js/api/topics';
const store = createStore(....);
const topicsState = store.state.topicListing;
TopicApi.list.mockImplementation(() => {
throw new Error('Something went wrong');
});
await store.dispatch('getTopics');
expect(topicsState.topics).toEqual([]);
Testing VueJs components
Refrain from testing Vuex modules in components
This greatly helps keeping the tests simple. If there is not overhead to include Vuex in the tests, we can do it. In most cases, mocking the actions directly should make things easier and simpler. We can assert that methods are called when triggered by user interactions by using mocks, or assert that events are emitted.
Stub components
Only test what has to be tested and brings value. Do not hesitate to stub components. This will make the tests simpler and faster. A common pattern is to stub font-awesome icons. This reduce boilerplate code by a great margin.
// Don't
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import BootstrapVue from 'bootstrap-vue';
import {
faAngleDown,
faBold,
faItalic,
faUnderline,
faStrikethrough,
faAlignLeft,
faAlignRight,
....
} from '@fortawesome/pro-regular-svg-icons';
Vue.use(BootstrapVue);
library.add({
faAngleDown,
faBold,
faItalic,
faUnderline,
faStrikethrough,
faAlignLeft,
faAlignRight,
...
});
// Do
const wrapper = mount(MyComponent, {
global: {
stubs: {
FontAwesomeIcon: true,
},
},
});
Of course, if we want to make sure icons are rendered as expected in components, we should not stub them.
Use fake timers to test code wrapped by setTimeout
We can test code wrapped within setTimeout
by using jest fake timers.
// Component code
...
onClick: () => {
setTimeout(() => {
this.myAction();
}, 50),
}
// Jest test code
const someButton = ...
const myActionSpy = jest.spyOn(wrapper.vm, 'myAction');
jest.useFakeTimers();
await someButton.trigger('click');
// After 10ms, the action should not have been fired
jest.runTimersToTime(10);
expect(myActionSpy).not.toHaveBeenCalled();
// 40ms more which makes it 50ms, the action should have been fired
jest.runTimersToTime(40);
expect(myActionSpy).toHaveBeenCalled();