sakhunzai
sakhunzai

Reputation: 14470

How to write Jest unit test for a Vue form component which uses a Vuex store?

I have a login form. When I fill out the login form with data and the login button is clicked:

Since this component heavily depends on the Vuex store, I'm unable to think of some valid test cases for this component.

I don't have experience with JavaScript ecosystem, so a verbose explanation would be appreciated.

Login.vue

<template>
  <b-col sm="6" offset-sm="3">
    <h1><span class="fa fa-sign-in"></span> Login</h1>
    <flash-message></flash-message>
    <!-- LOGIN FORM -->
    <div class="form">
        <b-form-group>
            <label>Email</label>
            <input type="text" class="form-control" name="email" v-model="email">
        </b-form-group>

        <b-form-group>
            <label>Password</label>
            <input type="password" class="form-control" name="password" v-model="password">
        </b-form-group>

        <b-btn type="submit" variant="warning" size="lg" @click="login">Login</b-btn>
    </div>

    <hr>

    <p>Need an account? <b-link :to="{name:'signup'}">Signup</b-link></p>
    <p>Or go <b-link :to="{name:'home'}">home</b-link>.</p>
  </b-col>

</template>

<script>
export default {
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    async login () {
      this.$store.dispatch('login', {data: {email: this.email, password: this.password}, $router: this.$router})
    }
  }
}
</script>

Upvotes: 9

Views: 14632

Answers (1)

Emile Bergeron
Emile Bergeron

Reputation: 17430

Vue test utils documentation says:

[W]e recommend writing tests that assert your component's public interface, and treat its internals as a black box. A single test case would assert that some input (user interaction or change of props) provided to the component results in the expected output (render result or emitted custom events).

So we shouldn't be testing bootstrap-vue components, that's the job of that project's maintainers.

Write code with unit tests in mind

To make it easier to test components, scoping them to their sole responsibility will help. Meaning that the login form should be its own SFC (single file component), and the login page is another SFC that uses the login form.

Here, we have the login form isolated from the login page.

<template>
    <div class="form">
        <b-form-group>
            <label>Email</label>
            <input type="text" class="form-control" 
                   name="email" v-model="email">
        </b-form-group>

        <b-form-group>
            <label>Password</label>
            <input type="password" class="form-control" 
                   name="password" v-model="password">
        </b-form-group>

        <b-btn type="submit" variant="warning" 
               size="lg" @click="login">
               Login
        </b-btn>
    </div>
</template>

<script>
export default {
    data() {
        return { email: '', password: '' };
    },
    methods: {
        login() {
            this.$store.dispatch('login', {
                email: this.email,
                password: this.password
            }).then(() => { /* success */ }, () => { /* failure */ });
        }
    }
}
</script>

I removed the router from the store action dispatch as it's not the store responsibility to handle the redirection when the login succeeds or fails. The store shouldn't have to know that there's a frontend in front of it. It deals with the data and async requests related to the data.

Test each part independently

Test the store actions individually. Then they can be mocked completely in components.

Testing the store actions

Here, we want to make sure the store does what it's meant to do. So we can check that the state has the right data, that HTTP calls are made while mocking them.

import Vuex from 'vuex';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import storeConfig from '@/store/config';

describe('actions', () => {
    let http;
    let store;

    beforeAll(() => {
        http = new MockAdapter(axios);
        store = new Vuex.Store(storeConfig());
    });

    afterEach(() => {
        http.reset();
    });

    afterAll(() => {
        http.restore();
    });

    it('calls login and sets the flash messages', () => {
        const fakeData = { /* ... */ };
        http.onPost('api/login').reply(200, { data: fakeData });
        return store.dispatch('login')
            .then(() => expect(store.state.messages).toHaveLength(1));
    });
    // etc.
});

Testing our simple LoginForm

The only real thing this component do is dispatching the login action when the submit button is called. So we should test this. We don't need to test the action itself since it's already tested individually.

import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import LoginForm from '@/components/LoginForm';

const localVue = createLocalVue();
localVue.use(Vuex);

describe('Login form', () => {

    it('calls the login action correctly', () => {
        const loginMock = jest.fn(() => Promise.resolve());
        const store = new Vuex.Store({
            actions: {
                // mock function
                login: loginMock
            }
        });
        const wrapper = mount(LoginForm, { localVue, store });
        wrapper.find('button').trigger('click');
        expect(loginMock).toHaveBeenCalled();
    });
});

Testing the flash message component

In that same vein, we should mock the store state with injected messages and make sure that the FlashMessage component displays the messages correctly by testing the presence of each message items, the classes, etc.

Testing the login page

The login page component can now be just a container, so there's not much to test.

<template>
    <b-col sm="6" offset-sm="3">
        <h1><span class="fa fa-sign-in"></span> Login</h1>
        <flash-message />
        <!-- LOGIN FORM -->
        <login-form />
        <hr>
        <login-nav />
    </b-col>
</template>

<script>
import FlashMessage from '@/components/FlashMessage';
import LoginForm from '@/components/LoginForm';
import LoginNav from '@/components/LoginNav';

export default {
    components: {
        FlashMessage,
        LoginForm,
        LoginNav,
    }
}
</script>

When to use mount vs shallow

The documentation on shallow says:

Like mount, it creates a Wrapper that contains the mounted and rendered Vue component, but with stubbed child components.

Meaning that child components from a container component will be replaced with <!-- --> comments and all their interactivity won't be there. So it isolates the component being tested from all the requirements its children may have.

The inserted DOM of the login page would then be almost empty, where the FlashMessage, LoginForm and LoginNav components would be replaced:

<b-col sm="6" offset-sm="3">
    <h1><span class="fa fa-sign-in"></span> Login</h1>
    <!-- -->
    <!-- LOGIN FORM -->
    <!-- -->
    <hr>
    <!-- -->
</b-col>

Upvotes: 31

Related Questions