Web CNLearn - Vue 2 - Storing Our State in Vuex

Today, we will start building our state by using Vuex and connecting our component to it. The commit for this post is here. We are continuing our Vue journey we started here where we created a “SearchInput” component. So what’s on the agenda for today?

  1. Create a Vuex store module for Search
  2. Connect the store to the component.
  3. Test that.

Easy right?

Creating modules for Vuex

At the moment, our Vuex store is empty (store/index.js file). It has the following:

import { createStore } from 'vuex';

export default createStore({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  },
});

Now you might be wondering why I am already splitting it into modules? To make my future life easier. For the first 10-12 posts, we probably won’t make use of more than one module but let’s do it anyway. So create a modules folder in the store directory and add a search.js file. This will involve all the state that is related to the state functionality of our application.

Then in the store/index.js file:

import { createStore } from 'vuex';
import search from '@/store/modules/search';

export default createStore({
  modules: {
    search,
  },
  state: () => ({}),
  mutations: {
  },
  actions: {
  },
});

We are importing the search module (currently empty). Now what will we first do with the search module? For now, it will contain a searchString in its state, a mutation that when comitted changes the state, and an action that will commit that mutation. We will then connect the SearchInput component to the store and rather than using the data attribute to display the search string above the text input, we will use the value from the Vuex store (why we are doing it like that will become more apparent in a later post).

Our search.js store module then looks like:

const search = {
  namespaced: true,
  state: () => ({
    searchString: '',
  }),
  mutations: {
    SET_SEARCH_STRING(state, searchString) {
      state.searchString = searchString;
    },
  },
  actions: {
    setSearch({ commit }, searchString) {
      commit('SET_SEARCH_STRING', searchString);
    },
  },
  getters: {},
};

export default search;

So the flow is like this:

  1. The setSearch action gets a searchString string and commits the ‘SET_SEARCH_STRING’ mutation with searchString as a payload.
  2. The ‘SET_SEARCH_STRING’ mutation mutates the state.searchString to whatever the searchString string is.

Yes it might seem unnecessary boilerplate…but that’s because it’s a simple thing now. As we expand our actions, they will commit multiple mutations at different times.

Connect the store to the component

Time to connect the search module in the Vuex store to our component. Let’s update our SearchInput.vue component:

<template>
  <div>
    <p data-test="searched_for">You searched for:</p>
    <p data-test="search_string" v-if="searchString.length > 0">
      {{ searchString }}
    </p>
    <span class="p-input-icon-left">
      <i class="pi pi-search" />
      <InputText
        type="text"
        class="search_box"
        id="search_box"
        v-model="localSearchString"
        placeholder="Search"
        data-test="search_box"
      />
    </span>
  </div>
</template>

<script>
import InputText from 'primevue/inputtext';
import { mapState, mapActions } from 'vuex';

export default {
  name: 'SearchInput',
  props: {},
  components: {
    InputText,
  },
  data() {
    return {
      localSearchString: '',
    };
  },
  watch: {
    localSearchString() {
      this.setSearch(this.localSearchString);
    },
  },
  computed: {
    ...mapState('search', ['searchString']),
  },
  methods: {
    ...mapActions('search', ['setSearch']),
  },
};
</script>

<style>
</style>

What’s new? Well, we are importing mapState and mapActions from our Vuex. We then map the searchString state from the search module to our component so we can access it as this.searchString. Please note I renamed the previously named searchString of the component to localSearchString. In the next post we will see what the difference between the two are when we add some more functionality to our actions. I also use mapActions to map the setSearch action from the search module to our component so we can dispatch it as this.setSearch. We also use the watch method of a Vue component to dispatch our setSearch action whenever the localSearchString value changes. This does mean it might dispatch it before the user stopped typing and we will “debounce” our function in the next post. For now, if you run npm run serve, everything should work exactly like before. BUT, our tests will fail since they don’t know about the $store. Let’s fix that now.

For now, we will not mock any functions because it’s all local and we are not calling any APIs. Later, when we do, we’ll introduce mocking.

Test test test

Expanding our tests

Let’s look at the tests we have for now. First of all, let’s import what we need.

import { mount } from '@vue/test-utils';
import SearchInput from '@/components/SearchInput.vue';
import PrimeVue from 'primevue/config';
import store from '@/store/index';

Then, let’s see what’s different about the first test.

describe('SearchInput', () => {
  test('Checks if the component got mounted', async () => {
    const wrapper = mount(SearchInput, {
      global: {
        plugins: [PrimeVue, store]
      }
    });
    expect(wrapper.find('[data-test="searched_for"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_box"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(false)
    // let's also check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
  });
});

I only included the first test in there. The actual assertions aren’t different. The only new thing is the we are passing in the Vuex store we imported. If we didn’t, the methods we had in our component wouldn’t work. I am also accessing the store and checking hte value of the searchString directly (if you already see something wrong with this approach, well done, we will fix it shortly). Let’s look at the second test:

 test('Checks if inputting values works', async () => {
    const wrapper = mount(SearchInput, {
      global: {
        plugins: [PrimeVue, store]
      }
    });
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
    // set the searchString data using setData method
    await wrapper.setData({ localSearchString: "hello" })
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(true)
    const search_string = wrapper.find(
      '[data-test="search_string"]').element.textContent
    expect(search_string).toBe("hello")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('hello')

    // let's also "write some text" in the textfield using setValue
    await wrapper.find('[data-test="search_box"]').setValue("general")
    const updated_search_string = wrapper.find('[data-test="search_string"]').element.textContent
    expect(updated_search_string).toBe("general")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('general')
  });

Ok so here we are first checking if the searchString in our search module in our Vuex store is an empty string. Then we are setting the data of our localSearchString to “hello” and checking that our “search_string” also became that (an action was called, mutation was committed, and a new state appeared (sorry DC)). Is the searchString now equal to “hello”? Well, yes it is. We then test the other way of ensuring changes are propagated which is to set the actual value in the text field. And everything works. Test 2 passes! OK let’s write another test. It starts just like our second one:

Our test fails

  test('Checks that the search value is blank in a new test', async () => {
    const wrapper = mount(SearchInput, {
      global: {
        plugins: [PrimeVue, store]
      }
    });
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
  });

We run npm run test:unit aaaaaaaand it fails. Oh no, what did we do wrong? Well you remember how we imported the store at the top? Well, that store was actually reused in each one of the tests -> meaning the state at the end of the first test was the initial state at the beginning of the second test (we didn’t have a problem there because we didn’t change the state at all). After the second test, however, we had already updated the test, so in our third test we didn’t start with a fresh state. While you might want to do that in some e2e test (which we’ll do at some point with Cypress, it looks cool no?), you definitely don’t want that in our unit tests. You don’t want the tests to depend on the order in which they are run. So let’s create a factory function that will return a new store. In fact, let’s create a factory function that will return the mounted Component with the correct plugins.

Rewriting tests

First of all, let’s import the ‘search’ module directly. Let’s also import the createStore() function from vuex.

import search from '@/store/modules/search';
import { createStore } from 'vuex';

Now let’s create a factory() function:

const factory = () => {
  const store = createStore({
    modules: {
      search
    }
  });
  const wrapper = mount(SearchInput, {
    global: {
      plugins: [PrimeVue, store]
    }
  });
  return { store, wrapper };
}

What is happening? Everytime we call this factory function we get a new wrapper with a new store. Now let’s remove some of the repeated code in our tests:

import { mount } from '@vue/test-utils';
import SearchInput from '@/components/SearchInput.vue';
import PrimeVue from 'primevue/config';
import search from '@/store/modules/search';
import { createStore } from 'vuex';

const factory = () => {
  const store = createStore({
    modules: {
      search
    }
  });
  const wrapper = mount(SearchInput, {
    global: {
      plugins: [PrimeVue, store]
    }
  });
  return { store, wrapper };
}

describe('SearchInput', () => {
  test('Checks if the component got mounted', async () => {
    const { store, wrapper } = factory();
    expect(wrapper.find('[data-test="searched_for"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_box"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(false)
    // let's also check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
  });
  test('Checks if inputting values works', async () => {
    const { store, wrapper } = factory();
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
    // set the searchString data using setData method
    await wrapper.setData({ localSearchString: "hello" })
    expect(wrapper.find('[data-test="search_string"]').exists()).toBe(true)
    const search_string = wrapper.find(
      '[data-test="search_string"]').element.textContent
    expect(search_string).toBe("hello")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('hello')

    // let's also "write some text" in the textfield using setValue
    await wrapper.find('[data-test="search_box"]').setValue("general")
    const updated_search_string = wrapper.find('[data-test="search_string"]').element.textContent
    expect(updated_search_string).toBe("general")
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('general')
  });
  test('Checks that the search value is blank in a new test', async () => {
    const { store, wrapper } = factory();
    // first check that the searchString in the search module state is null
    expect(store.state.search.searchString).toBe('')
  });
});

Now, even if you rearranged the tests in a different order, made as many changes as you wanted in one test, still would not be connected to one another :) And that’s it for today. Next time, we will also use GitHub actions to run our tests as part of our continuous integration.