loading...

Write Great Tests for Vuex

shannonarcher profile image Shannon Archer ・3 min read

After reading this post you should go over to Manning's website, purchase and then read "Testing Vue.js Applications" by Edd Yerburgh, the author of @vue/test-utils and the testing expert on the Vue core team.

After working with Vuex for two years, I explain the two methods I've used for testing my application's store and which one I've found to be more effective in a growing enterprise application.

Test the Whole Module

Test our entire module, actions / commits / getters, together by creating an instance of our Vuex store and testing through the store's interface.

This level of testing crosses the line into integration testing (with all its positives and negatives) but since our actions, mutations and getters are so highly coupled anyway it can make sense for a few reasons.

+ We test commits and actions together. Dispatching actions and then checking that all of our external actions, service calls and state changes occur seems like a sensible and intuitive way of testing our module.

+ When refactoring we are more likely to catch bugs in the communication of actions, commits and getters.

+ By only being able to build state through our actions when testing our getters, our coverage tools will give immediate feedback when branches of code are no longer reachable in the module.

However,

- We need to create an instance of the store with all other module dependencies. This can create extra boilerplate that then needs to be maintained and updated.

- I've found that while this method works well for small apps with fairly decoupled modules, it won't scale as our application becomes more complex.

- In the hands of a poor developer, this method can quickly become a hard-to-read, unmaintainable mess.

For example,

// app.module.spec.js
import Vuex from 'vuex';
import AppModule from '~store/app.module';
import merge from 'lodash/merge';

// a factory function is a good way 
// of DRY-ing up your tests
function createStore() {
    const getPosts = jest.fn();
    return {
        store: new Vuex.Store({
            modules: {
                app: AppModule,
                blog: { // a mocked dependency
                    namespaced: true,
                    actions: {
                        getPosts,
                    },
                },
            },
        }),
        spies: {
            // use the full path to the action
            // to make it clear what module it is in
            'blog/getPosts': getPosts, 
        },
    };
}

test('refreshing app state', async () => {
    const {store, spies} = createStore();
    const refreshPromise = store.dispatch('app/refresh');

    expect(store.getters['app/isLoading']).toBeTruthy();
    expect(spies['blog/getPosts']).toHaveBeenCalled();
    await refreshPromise;
    expect(store.getters['app/isLoading']).toBeFalsy();
});

test('refreshing app state failure', async () => {
    const error = new Error();
    const {store, spies} = createStore();
    spies['blog/getPosts'].mockImplementation(() => throw error);

    const refreshPromise = store.dispatch('app/refresh');
    expect(store.getters['app/isLoading']).toBeTruthy();
    expect(spies['blog/getPosts']).toHaveBeenCalled();
    await refreshPromise;
    expect(store.getters['app/error']).toBe(error);
    expect(store.getters['app/isLoading']).toBeFalsy();
});

Test the Parts of the Module

Test our module by testing each of the parts (actions, commits, getters) that make up the module directly.

+ This is the quickest and most maintainable way of testing a module, especially when refactoring.

+ It scales with the complexity of our module as we have full control over the parameters we are feeding the unit.

+ By staying true to unit testing we get easy-to-write tests.

However,

- Because we aren't testing the integration of the parts of the module this method won't protect against that kind of bug. The main caveat of unit testing.

- A poor developer can quickly fall into the common trap of writing tests that contain far too many implementation details i.e. test('don't call getBlogs when isLoading is true').

For example,

// app.actions.spec.js
import Vuex from 'vuex';
import {refresh} from '~store/app.actions';
import merge from 'lodash/merge';

test('refreshing app state', async () => {
    const store = {
        commit: jest.fn(),
        dispatch: jest.fn(),
    };

    await refresh(store);
    expect(store.dispatch).toHaveBeenCalledWith('blog/getPosts', null, {root: true});
});

test('refreshing app state failure', async () => {
    const error = new Error();
    const store = {
        commit: jest.fn(),
        dispatch: jest.fn().mockImplementationOnce(() => throw error),
    };

    await refresh(store);
    expect(store.dispatch).toHaveBeenCalledWith('blog/getPosts', null, {root: true});
    expect(store.commit).toHaveBeenCalledWith('setError', error)
});

Final thoughts

At the end of the day, you as a developer need to look at your testing strategies and find the balance between the different kinds of tests to maximise bug reduction and the reliability of your application.

I have written tests in both of the ways mentioned above while at Zoro. For the purpose of confidently shipping bug-free changes to my applications Vuex store, testing actions, commits and getters directly provides the right balance between ease-of-writing and reliability when accompanied by a suite of e2e tests.

Posted on by:

Discussion

pic
Editor guide