Vue SPA with Laravel API Part 8

The complete source code of this series is available on GitHub.

In this part we want to setup Jest and use it to test our Vue components. First let’s install some dependencies:

npm install --save-dev jest vue-jest babel-jest jest-serializer-vue @vue/test-utils

We install Jest, some jest helpers for vue, babel and snapshot serializing and the Vue test utils. Now we have to change our ESLint config file .eslintrc.js:

module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:vue/recommended'
  ],
  rules: {
    // override/add rules settings here, such as:
    // 'vue/no-unused-vars': 'error'
  },
  env: {
    amd: true,
    jest: true
  },
  parserOptions: {
    ecmaVersion: 8
  }
}

We add the jest env option and ecmaVersion 8 to it. With this we can use async await in our tests and eslint won’t complain.

Next we need the configuration for Jest. Add this to package.json:

"jest": {
    "moduleFileExtensions": [
        "js",
        "vue"
    ],
    "moduleNameMapper": {
        "^@/(.*)$": "<rootDir>/resources/assets/js/components/$1"
    },
    "transform": {
        "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
        ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
    },
      "snapshotSerializers": [
        "<rootDir>/node_modules/jest-serializer-vue"
    ]
},
"babel": {
    "env": {
        "test": {
            "presets": [
                ["env", { "targets": { "node": 8 }}]
            ]
        }
    }
}

and these two in our scripts section:

"test": "jest",
"test-watch": "npm run test -- --watch"

And we want travis to run Jest too, so in .travis.yml we change our script part to:

script:
- vendor/bin/phpunit
- npm run test
- npm run production

The setup is now done and we can start writing our tests. We will begin with very easy snapshot tests for our Login and Register components.

tests/Javascript/Login.spec.js:

import { mount } from '@vue/test-utils';
import Login from '@/Login.vue';

describe('Login.vue', () => {

    it('matches the snapshot', () => {
        const wrapper = mount(Login)
        expect(wrapper.html()).toMatchSnapshot();
    });

});

tests/Javascript/Register.spec.js:

import { mount } from '@vue/test-utils';
import Register from '@/Register.vue';

describe('Register.vue', () => {

    it('matches the snapshot', () => {
        const wrapper = mount(Register)
        expect(wrapper.html()).toMatchSnapshot();
    });

});

We import Vue test utils, mount the component and expect the rendered HTML from the template to match our snapshot. On our first Jest run it will save the two snapshots in a new directory and after that use these saved snapshots to compare the current version to. If you changed the resulting HTML somehow, you will have to test manually and regenerate the snapshots after that. It’s not so useful for components that will change often, but for Login and Register it’s pretty nice, since we won’t be changing them that often.

Now we get to the more complex tests. We will start with the Dashboard test. As always a step by step walkthrough is below the code.

tests/Javascript/Dashboard.spec.js:

jest.mock('axios', () => ({
    get: jest.fn(() => Promise.resolve({ 
        data: [{"id":5,"name":"test","user_id":1,"created_at":"2018-07-05 12:36:35","updated_at":"2018-07-05 12:36:35","board_lists":[{"id":17,"name":"abc","board_id":5,"order":1,"created_at":"2018-07-05 12:36:41","updated_at":"2018-07-05 12:36:51","cards":[]},{"id":18,"name":"def","board_id":5,"order":0,"created_at":"2018-07-05 12:36:47","updated_at":"2018-07-05 12:36:51","cards":[{"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"},{"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}]}]},{"id":6,"name":"another board","user_id":1,"created_at":"2018-07-05 17:49:04","updated_at":"2018-07-05 17:49:04","board_lists":[]}]
    })),
    post: jest.fn(() => Promise.resolve({ 
        data: {"name":"yet another board","user_id":1,"updated_at":"2018-07-05 17:49:20","created_at":"2018-07-05 17:49:20","id":7}
    })),
    delete: jest.fn(() => Promise.resolve())
}));

import { shallowMount } from '@vue/test-utils';
import Dashboard from '@/Dashboard.vue';
import axios from 'axios';

describe('Dashboard.vue', () => {
    let wrapper;

    beforeEach(async () => {
        wrapper = await shallowMount(Dashboard, {
            stubs: ['router-link']
        });

        jest.resetModules();
        jest.clearAllMocks();
    });

    it('shows the users boards', () => {
        expect(wrapper.findAll('router-link-stub').length).toBe(2);
    });

    it('can create a new board', async () => {
        const input = wrapper.find('form input');
        input.setValue('yet another board');
        await wrapper.find('button').trigger('click');

        expect(axios.post).toBeCalledWith('/boards', { name: 'yet another board' });
        expect(wrapper.findAll('router-link-stub').length).toBe(3);
    });

    it('can delete a board', async () => {
        await wrapper.find('.board-navs a').trigger('click');

        expect(axios.delete).toBeCalledWith('/boards/5');
        expect(wrapper.findAll('router-link-stub').length).toBe(1);
    });

});

The very first thing we do is to define an axios mock for our test. Since we don’t want axios to make real API calls in our tests we will have to mock every method of axios we use in our component. Each mocked method is pretty simple: We just return data like the real API would. I got this data by browsing the webpage and inspecting the API responses for the actions we’re testing here. We them import axios from axios. This loads our axios mock so we can use it later. Before each test we shallowMount the Dashboard component and stub out all router links. We also reset all mocks, so our tests are isolated from each other. The first test just checks that we have 2 router-link-stubs in the resulting html, which means 2 boards.

The next test finds the form input for the new board form, inputs the name for the new board and triggers a click on the button. We use async await on each test that uses our axios mock, since we have to wait for it’s promise to resolve before our expectations should check. Then we expect that the post method of axios was called with the given arguments and we now have 3 router-link-stubs. The last test triggers a click on the delete link of a board and we check that the API was called and the board disappeared.

The next test file is a quite a bit longer, tests/Javascript/Board.spec.js:

jest.mock('axios', () => ({
    get: jest.fn(() => Promise.resolve({ 
        data: {"id":5,"name":"test","user_id":1,"created_at":"2018-07-05 12:36:35","updated_at":"2018-07-05 12:36:35","user":{"id":1,"name":"admin","email":"admin@example.com","created_at":"2018-07-03 14:38:30","updated_at":"2018-07-03 14:38:30"},"board_lists":[{"id":18,"name":"def","board_id":5,"order":0,"created_at":"2018-07-05 12:36:47","updated_at":"2018-07-05 12:36:51","cards":[{"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"},{"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}]},{"id":17,"name":"abc","board_id":5,"order":1,"created_at":"2018-07-05 12:36:41","updated_at":"2018-07-05 12:36:51","cards":[{"name":"test card 3","order":0,"description":"","board_list_id":17,"updated_at":"2018-07-06 11:55:39","created_at":"2018-07-06 11:55:39","id":35}]}]}
    })),
    post: jest.fn(() => Promise.resolve({ 
        data: {"name":"new list","order":2,"board_id":5,"updated_at":"2018-07-06 11:39:56","created_at":"2018-07-06 11:39:56","id":19,"cards":[]}
    })),
    put: jest.fn(() => Promise.resolve({ 
        data: {"id":5,"name":"new board name","user_id":1,"created_at":"2018-07-05 12:36:35","updated_at":"2018-07-05 12:36:35","user":{"id":1,"name":"admin","email":"admin@example.com","created_at":"2018-07-03 14:38:30","updated_at":"2018-07-03 14:38:30"},"board_lists":[{"id":18,"name":"def","board_id":5,"order":0,"created_at":"2018-07-05 12:36:47","updated_at":"2018-07-05 12:36:51","cards":[{"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"},{"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}]},{"id":17,"name":"abc","board_id":5,"order":1,"created_at":"2018-07-05 12:36:41","updated_at":"2018-07-05 12:36:51","cards":[]}]}
    })),
    patch: jest.fn(() => Promise.resolve())
}));

import { mount } from '@vue/test-utils';
import Board from '@/Board.vue';
import { EventBus } from '@/../EventBus.js';
import axios from 'axios';

describe('Board.vue', () => {
    let wrapper;

    beforeEach(async () => {
        wrapper = await mount(Board, {
            mocks: {
                $route: { path: '/board/5', params: { id: 5 } }
            },
            stubs: ['list', 'modal']
        });

        jest.resetModules();
        jest.clearAllMocks();
    });

    it('shows the lists', () => {
        expect(wrapper.vm.board.board_lists.length).toBe(2);
        expect(wrapper.findAll('list-stub').length).toBe(2);
    });

    it('can update its name', async () => {
        wrapper.find('.board-name').trigger('click');

        let input = wrapper.find({ ref: 'edit' });
        input.setValue('new board name');
        await input.trigger('keyup.enter');

        expect(axios.put).toBeCalledWith('/boards/5', { name: 'new board name' });
        expect(wrapper.vm.board.name).toBe('new board name');
    });


    it('listens for an open-modal event', async () => {
        const spy = jest.spyOn(EventBus, '$on');

        // mount again so our spy gets called
        await mount(Board, {
            mocks: {
                $route: { path: '/board/5', params: { id: 5 } }
            },
            stubs: ['list', 'modal']
        });

        expect(spy).toBeCalled();
    });

    it('can create a new list', async () => {
        const input = wrapper.find('.new-list input');
        input.setValue('new list');
        await wrapper.find('.new-list button').trigger('click');

        expect(axios.post).toBeCalledWith('/boardLists', { board_id: 5, name: 'new list', order: 2 });
        
        expect(wrapper.vm.board.board_lists.length).toBe(3);
    });

    it('can remove a list from the view', () => {
        wrapper.vm.deleteList({"id":17,"name":"abc","board_id":5,"order":1,"created_at":"2018-07-05 12:36:41","updated_at":"2018-07-05 12:36:51","cards":[]});
        expect(wrapper.vm.board.board_lists.length).toBe(1);
    });

    it('sorts lists by order', () => {
        // expect order same as mock data
        expect(wrapper.vm.orderedList[0].name).toBe('def');

        // change order
        wrapper.vm.board.board_lists[0].order = 1;
        wrapper.vm.board.board_lists[1].order = 0;

        // expect order reversed compared to mock data
        expect(wrapper.vm.orderedList[0].name).toBe('abc');
    });

    it('can update the list order', () => {
        let helper;

        expect(wrapper.vm.board.board_lists[0].name).toBe('def');
        expect(wrapper.vm.board.board_lists[0].order).toBe(0);

        // switch list order without setting the order attribute
        helper = wrapper.vm.board.board_lists[0]
        wrapper.vm.board.board_lists[0] = wrapper.vm.board.board_lists[1];
        wrapper.vm.board.board_lists[1] = helper;

        // save new order
        wrapper.vm.updateListOrder();

        expect(axios.patch).toBeCalled();
        expect(wrapper.vm.board.board_lists[0].name).toBe('abc');
        expect(wrapper.vm.board.board_lists[0].order).toBe(0);
        expect(wrapper.vm.board.board_lists[1].name).toBe('def');
        expect(wrapper.vm.board.board_lists[1].order).toBe(1);

    });

    it('can update the card order', () => {
        expect(wrapper.vm.board.board_lists[0].cards[0].name).toBe('test card 1');
        expect(wrapper.vm.board.board_lists[0].cards[0].order).toBe(0);

        // move card from list 1 to list 0 at the first place (moving test card 1 to second place)
        wrapper.vm.board.board_lists[0].cards.unshift(wrapper.vm.board.board_lists[1].cards[0]);
        // remove card from list 1
        wrapper.vm.board.board_lists[1].cards.splice(0,1);

        wrapper.vm.updateCardOrder();

        expect(axios.patch).toBeCalled();
        expect(wrapper.vm.board.board_lists[0].cards[0].name).toBe('test card 3');
        expect(wrapper.vm.board.board_lists[0].cards[0].order).toBe(0);
        expect(wrapper.vm.board.board_lists[0].cards[1].name).toBe('test card 1');
        expect(wrapper.vm.board.board_lists[0].cards[1].order).toBe(1);
    });

});

We start off by defining another axios mock again. In our beforeEach method we create 2 stubs again: List and Modal. We also have to create a mock of the $routes object of vue-router. The first test just checks again that the fetched data on mounting is correctly shown to the user. The second test is for the edit process again. Here we have to add a click as the first action since we have to click the link to show the input first. The create new list test is the same as we’ve seen in the Dashboard test. The delete test doesn’t call the API but listens for an event from the List component to delete it, so we just call the respecting method here. The listens for an open-modal event test needs to test our EventBus. Here we create a Jest spy on it, mount our component again so that our spy is called and then expect that it has been called.

The it sorts lists by order test expects the given sorting order, then reverses the order attributed and checks that the computed method of vue reversed the order correctly. Now there are two tests left which test that the list and card order can be updated via drag&drop. For this we expect the given sorting order, then change the order of our objects instead of the order attribute and call the save method. We then expect that the patch method of axios was called, and the new order including the order attributed have been changed.

tests/Javascript/List.spec.js:

jest.mock('axios', () => ({
    put: jest.fn(() => Promise.resolve({ 
        data: {"id":18,"name":"new name","board_id":5,"order":0,"created_at":"2018-07-05 12:36:47","updated_at":"2018-07-05 12:36:51","cards":[{"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"},{"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}]}
    })),
    post: jest.fn(() => Promise.resolve({ 
        data: {"id":35,"name":"new card","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}
    })),
    delete: jest.fn(() => Promise.resolve())
}));

import { mount } from '@vue/test-utils';
import List from '@/List.vue';
import axios from 'axios';

describe('List.vue', () => {
    let wrapper;

    beforeEach(async () => {
        wrapper = await mount(List, {
            propsData: {
                list: {"id":18,"name":"def","board_id":5,"order":0,"created_at":"2018-07-05 12:36:47","updated_at":"2018-07-05 12:36:51","cards":[{"id":33,"name":"test card 1","board_list_id":18,"order":1,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"},{"id":34,"name":"test card 2","board_list_id":18,"order":0,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}]}
            },
            stubs: ['card']
        });

        jest.resetModules();
        jest.clearAllMocks();
    });

    it('shows the cards', () => {
        expect(wrapper.vm.list.cards.length).toBe(2);
        expect(wrapper.findAll('card-stub').length).toBe(2);
    });

    it('can emit an update-card-order event', () => {
        wrapper.vm.updateOrder();
        expect(wrapper.emitted()['update-card-order']).toBeTruthy();
    });

    it('can remove a card from the view', () => {
        wrapper.vm.deleteCard({"id":33,"name":"test card 1","board_list_id":18,"order":1,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"});
        expect(wrapper.vm.list.cards.length).toBe(1);
    });

    it('can delete itself', async () => {
        await wrapper.find('.list-navs a:nth-child(2)').trigger('click');

        expect(axios.delete).toBeCalledWith('/boardLists/18');
        expect(wrapper.emitted()['delete-list']).toBeTruthy();
    });

    it('can update its name', async () => {
        wrapper.find('.list-navs a:nth-child(1)').trigger('click');

        const input = wrapper.find({ ref: 'edit' });
        input.setValue('new name');
        await input.trigger('keyup.enter');

        expect(axios.put).toBeCalledWith('/boardLists/18', { name: 'new name' });
        expect(wrapper.vm.list.name).toBe('new name');
    });

    it('can create a new card', async () => {
        wrapper.find('.card-footer a').trigger('click');

        const input = wrapper.find({ ref: 'new' });
        input.setValue('new card');
        await input.trigger('keyup.enter');

        expect(axios.post).toBeCalledWith('/cards', { list_id: 18, name: 'new card', order: 2 });
        
        expect(wrapper.vm.list.cards.length).toBe(3);
    });

    it('sorts card by order', () => {
        // expect order reversed compared to propsData
        expect(wrapper.vm.orderedList).toEqual([{"id":34,"name":"test card 2","board_list_id":18,"order":0,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"},
        {"id":33,"name":"test card 1","board_list_id":18,"order":1,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}]);
    });

});

The only things that we haven’t seen yet are that we provide propsData to the component and the test for emitted events, which is pretty self-explanatory.

tests/Javascript/Card.spec.js:

jest.mock('axios', () => ({
    put: jest.fn(() => Promise.resolve({ 
        data: {"id":33,"name":"new name","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}
    })),
    delete: jest.fn(() => Promise.resolve())
}));

import { mount } from '@vue/test-utils';
import Card from '@/Card.vue';
import { EventBus } from '@/../EventBus.js';
import axios from 'axios';

describe('Card.vue', () => {
    let wrapper;

    beforeEach(async () => {
        wrapper = await mount(Card, {
            propsData: {
                card: {"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}
            }
        });

        jest.resetModules();
        jest.clearAllMocks();
    });

    it('can show a description icon', () => {
        expect(wrapper.find('.fa-comment').isVisible()).toBe(true);
    });

    it('can hide a description icon', () => {
        wrapper.setProps({
            card: {"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}
        });

        expect(wrapper.find('.fa-comment').exists()).toBe(false);
    });

    it('can delete itself', async () => {
        await wrapper.find('.card-navs a:nth-child(2)').trigger('click');

        expect(axios.delete).toBeCalledWith('/cards/33');
        expect(wrapper.emitted()['delete-card']).toBeTruthy();
    });

    it('can update its name', async () => {
        wrapper.find('.card-navs a:nth-child(1)').trigger('click');

        let textarea = wrapper.find('textarea');
        textarea.element.value = 'new name';
        textarea.trigger('input');
        await textarea.trigger('keyup.enter');

        expect(axios.put).toBeCalledWith('/cards/33', { name: 'new name' });
        expect(wrapper.vm.card.name).toBe('new name');
    });

    it('can emit an open-modal event', () => {
        const spy = jest.spyOn(EventBus, '$emit');

        wrapper.find('.card-content div div').trigger('click');

        expect(spy).toBeCalled();
    });

});

Here we added two visual tests: It can show a description icon, and it can hide the icon. One thing to note in the can update its name test is that we have a textarea here. This means the setValue method we used until now doesn’t work so we have to set the content of the textarea ourself and have to trigger the input event after that so the v-model updates.

tests/Javascript/Modal.spec.js:

jest.mock('axios', () => ({
    put: jest.fn(() => Promise.resolve({ 
        data: {"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The new description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}
    }))
}));

import { mount } from '@vue/test-utils';
import Modal from '@/Modal.vue';
import axios from 'axios';

describe('Modal.vue', () => {
    let wrapper;

    beforeEach(async () => {
        wrapper = await mount(Modal, {
            propsData: {
                card: {"id":33,"name":"test card 1","board_list_id":18,"order":0,"description":"The description.","created_at":"2018-07-05 13:51:54","updated_at":"2018-07-05 13:52:14"}
            }
        });

        jest.resetModules();
        jest.clearAllMocks();
    });

    it('can show the cards description', () => {
        expect(wrapper.find('.modal-body').text()).toContain('The description.');
    });

    it('can show the no description text', () => {
        wrapper.setProps({
            card: {"id":34,"name":"test card 2","board_list_id":18,"order":1,"description":"","created_at":"2018-07-05 13:52:02","updated_at":"2018-07-05 13:52:02"}
        });

        expect(wrapper.find('.modal-body').text()).toContain('No description');
    });

    it('can update the cards description', async () => {
        wrapper.find('small > a').trigger('click');

        const textarea = wrapper.find('textarea');
        textarea.element.value = 'The new description.';
        textarea.trigger('input');
        await wrapper.find('.button').trigger('click');

        expect(axios.put).toBeCalledWith('/cards/' + wrapper.vm.card.id, { description: 'The new description.' });
        expect(wrapper.vm.card.description).toBe('The new description.');
    });

    it('can emit a close-modal event', () => {
        wrapper.find('.modal-close-button').trigger('click');
        
        expect(wrapper.emitted()['close-modal']).toBeTruthy();
    });

});

Here we check for some text to be displayed, the rest of the tests are the same as we’ve seen before.

And with that we have tested all our Vue components with Jest and all API methods with feature tests and PHPUnit. It looks like a lot of code in this part, but if you think about how much we covered with these tests, I would say Jest and vue test utils have frontend testing pretty approachable.

This was the 8th and last part of our series in which we created a Trello clone as a Vue SPA with a Laravel API as it’s backend. We have covered quite a lot: A complete API, backend and frontend testing, continuous deployment, Vue and some basic designing as well as some extra tools like Linters. The complete source code of this series is available on GitHub.