Web CNLearn - Vue Frontend 5 - Testing Vuex Mutations and Actions

Testing mutations is the easy test. We don’t have to mock anything. The mutations only change the state synchronously so we’re testing simple things :) I like. In the tests/unit folder I have a VuexSearchModuleMutations.spec.js file. We’ll only go in detail through one, ok two, of the tests.

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

const factory = () => {
  const store = createStore({
    modules: {
      search,
    },
  });
  return store;
};

describe('Vuex Search Module Mutations', () => {
  test('Tests SET_SEARCH_STRING mutation', () => {
    const store = factory();
    expect(store.state.search.searchString).toBe('');
    store.commit('search/SET_SEARCH_STRING', '你们不好');
    expect(store.state.search.searchString).toBe('你们不好');
  });
  test('Tests CLEAR_SEGMENTED_WORDS mutation', () => {
    const store = factory();
    expect(store.state.search.segmentedWords.size).toEqual(0);
    // let's manually add some words in there
    // note that I am not using the mutation, I am setting the state directly
    const segmentedWordsArray = ['我们', '是', '你们', '的', '朋友'];
    segmentedWordsArray.forEach((word, index) => {
      store.state.search.segmentedWords.set(index, word);
    });
    expect(store.state.search.segmentedWords.size).toEqual(5);
    // now let's use the mutation tested here
    store.commit('search/CLEAR_SEGMENTED_WORDS');
    // now the size should be 0 again
    expect(store.state.search.segmentedWords.size).toEqual(0);
  });

So what is happening? Well, our factory function is just the store. In the first test, we create a store. We check that the searchString is empty. We then called the SET_SEARCH_STRING mutation and then check again what the searchString state is. Easy!

For the second test, we create a store. We check that the segmentedWords Map has a size of 0. We then add a few words to the segmentedWords map and check its size again: it’s 5. Finally, we commit the mutation and test that it worked.

Testing Actions

This one’s not as easy but it’s more fun. Ok so what actions do we have? we have setSearch (easy), addToSegmentedWords (easy), addToCache (easy), addToCurrentWords (easy). Why are those easy? Because they simply commit mutations (which we already tested) and everything is synchronous. Then we have findWords (not easy) and search (less not easy…). The last one actually calls findWords so we will test in that order. The more difficult one to test is the findWords one since that is the one calling the API. Ok let’s go through the tests for setSearch and addToSegmentedWords. The addToCache, addToCurrentWords we won’t go through but they will be in the commit at the end of this post. And obviously we will go through the last two.

For setSearch and addToSegmentedWords we have:

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

const factory = () => {
  const store = createStore({
    modules: {
      search,
    },
  });
  return store;
};

describe('Vuex Search Module Actions', () => {
  test('Tests setSearch action', () => {
    const store = factory();
    expect(store.state.search.searchString).toBe('');
    store.dispatch('search/setSearch', '你们不好');
    expect(store.state.search.searchString).toBe('你们不好');
  });
  test('Tests addToSegmentedWords action', () => {
    const store = factory();
    expect(store.state.search.segmentedWords.size).toEqual(0);
    const wordsArray = ['我们', '是', '你们'];
    // dispatch the action, size should increase by three
    store.dispatch('search/addToSegmentedWords', wordsArray);
    expect(store.state.search.segmentedWords.size).toEqual(wordsArray.length);
  });
});

Mocking API Calls

They are very similar to the mutations ones, but obviously we call dispatch. Won’t really say more about them. OK let’s think of the findWords action. We know it calls the API. Which is fine if our API is running but that might not be the case during tests. So we need to mock the API calls. How do we do that? In my VuexSearchModuleActions.spec.js (I have naming issues I know…)

import Dictionary from '@/services/Dictionary';
import { flushPromises } from '@vue/test-utils';

jest.mock('@/services/Dictionary');
beforeEach(() => {
  jest.clearAllMocks();
});

Ok what’s happening there? I’m indicating that the Dictionary module should be mocked. How do we return the data we want though? I wrote my tests based on the information I read here. Let’s give it a go. We are going to test our findWords action by sending in 1 word (我) that is not in the cache (otherwise it wouldn’t hit the API) so the API will get hit once (I probably need to learn the right terminology).

test('Tests the findWords action (1) - empty cache - all words are found', async () => {
    const store = factory();
    const mockData = [
      {
        id: 40806,
        simplified: '我',
        traditional: '我',
        pinyin_num: 'wo3',
        pinyin_accent: 'wǒ',
        pinyin_clean: 'wo',
        pinyin_no_spaces: 'wo',
        also_written: '',
        classifiers: '',
        definitions: 'I; me; my',
        frequency: 1293594,
      },
    ];

    Dictionary.getWord.mockResolvedValueOnce({ data: mockData });
    // the whole state is empty
    // let's dispatch the action with an array of 1 word
    expect(store.state.search.currentWords.size).toEqual(0);
    const word = '我';
    await store.dispatch('search/findWords', [word]);
    // test if the correct API endpoint was called
    expect(Dictionary.getWord).toHaveBeenCalledTimes(1);
    expect(store.state.search.currentWords.size).toEqual(1);
    expect(store.state.search.currentWords.has(word)).toBe(true);
  });

Notice the way I use mockResolvedValueOnce: Dictionary.getWord.mockResolvedValueOnce({ data: mockData });. When the getWord function is called, the data I manually specified is returned. If the action was dispatched correctly, we’d expect the word to be in the currentWords. And it is! That wasn’t so bad. Ok what if we send two words….

test('Tests the findWords action (2) - empty cache - two words are found', async () => {
    const store = factory();
    const mockData1 = [
      {
        id: 40806,
        simplified: '我',
        traditional: '我',
        pinyin_num: 'wo3',
        pinyin_accent: 'wǒ',
        pinyin_clean: 'wo',
        pinyin_no_spaces: 'wo',
        also_written: '',
        classifiers: '',
        definitions: 'I; me; my',
        frequency: 1293594,
      },
    ];
    const mockData2 = [
      {
        id: 49583,
        simplified: '是',
        traditional: '是',
        pinyin_num: 'shi4',
        pinyin_accent: 'shì',
        pinyin_clean: 'shi',
        pinyin_no_spaces: 'shi',
        also_written: '',
        classifiers: '',
        definitions: 'is; are; am; yes; to be',
        frequency: 957407,
      },
      {
        id: 49601,
        simplified: '是',
        traditional: '昰',
        pinyin_num: 'shi4',
        pinyin_accent: 'shì',
        pinyin_clean: 'shi',
        pinyin_no_spaces: 'shi',
        also_written: '',
        classifiers: '',
        definitions: 'variant of 是(shì); (used in given names)',
        frequency: 957407,
      },
    ];
    Dictionary.getWord.mockResolvedValueOnce({ data: mockData1 })
      .mockResolvedValueOnce({ data: mockData2 });
    // the whole state is empty
    // let's dispatch the action with an array of 1 word
    expect(store.state.search.currentWords.size).toEqual(0);
    const word1 = '我';
    const word2 = '是';
    await store.dispatch('search/findWords', [word1, word2]);
    // test if the correct API endpoint was called
    expect(Dictionary.getWord).toHaveBeenCalledTimes(2);
    expect(store.state.search.currentWords.size).toEqual(2);
    expect(store.state.search.currentWords.has(word1)).toBe(true);
    expect(store.state.search.currentWords.has(word2)).toBe(true);
  });

Oh. so all I did was chain two mockResolvedValueOnce functions? yup. It is not as bad as I thought :) Mocking API calls was slightly new to me so. There are quite a few more tests in the commit link at the end of the post. There are 6 different ones for the findWords action. Why? Because we could request a word that wasn’t found. Or one that was found and one that wasn’t found. Or one that was in the cache, one that is not found, and then two that were found. There are many different combinations and I tried to get a few of them in there.

Let’s, however, also look at the search action test. There’s only 1 because the various possible combinations occcur in the findWords action.

  test('Tests the search action - simple test - 2 words, neither in cache', async () => {
    // this test is an example of one where the segmenter splits into a word that does
    // not exist. "是谁" so it will still call for it even though both 是 and 谁 are in the cache
    const store = factory();
    const mockData1 = [];
    const mockData2 = [
      {
        id: 49583,
        simplified: '是',
        traditional: '是',
        pinyin_num: 'shi4',
        pinyin_accent: 'shì',
        pinyin_clean: 'shi',
        pinyin_no_spaces: 'shi',
        also_written: '',
        classifiers: '',
        definitions: 'is; are; am; yes; to be',
        frequency: 957407,
      },
      {
        id: 49601,
        simplified: '是',
        traditional: '昰',
        pinyin_num: 'shi4',
        pinyin_accent: 'shì',
        pinyin_clean: 'shi',
        pinyin_no_spaces: 'shi',
        also_written: '',
        classifiers: '',
        definitions: 'variant of 是(shì); (used in given names)',
        frequency: 957407,
      },
    ];
    const mockData3 = [
      {
        id: 94175,
        simplified: '谁',
        traditional: '誰',
        pinyin_num: 'shei2',
        pinyin_accent: 'shéi',
        pinyin_clean: 'shei',
        pinyin_no_spaces: 'shei',
        also_written: '',
        classifiers: '',
        definitions: 'who',
        frequency: 46737,
      },
    ];
    // mock the calls that will happen
    Dictionary.getWord.mockResolvedValueOnce({ data: mockData1 })
      .mockResolvedValueOnce({ data: mockData2 })
      .mockResolvedValueOnce({ data: mockData3 });
    // the whole state is empty
    expect(store.state.search.currentWords.size).toEqual(0);
    expect(store.state.search.cachedWords.size).toEqual(0);
    expect(store.state.search.segmentedWords.size).toEqual(0);
    // let's dispatch the action with an array of 1 word

    const word1 = '是谁';
    const word2 = '是';
    const word3 = '谁';
    await store.dispatch('search/search', word1);
    // let's wait for the actions to happen
    await flushPromises();
    // test if the correct API endpoint was called 1 time
    // it was called one time because of segmenter error
    expect(Dictionary.getWord).toHaveBeenCalledTimes(3);
    expect(store.state.search.currentWords.size).toEqual(2);
    expect(store.state.search.currentWords.has(word2)).toBe(true);
    expect(store.state.search.currentWords.has(word3)).toBe(true);
    expect(store.state.search.cachedWords.size).toEqual(2);
    expect(store.state.search.cachedWords.has(word2)).toBe(true);
    expect(store.state.search.cachedWords.has(word3)).toBe(true);
  })

I’ll let you decipher that yourself.

I haven’t tested any errors yet but I will sometime in the future. The way my code is written now it shouldn’t ever request a non-Chinese character from the API.

You know how I said that we are gonna do component testing with Vuex actions/mutations in a new post? Well, I really wanted to but I was getting some errors and I didn’t like them. So let’s see. It relates to the SearchInput.spec.js test. The SearchResults test is not affected as we set the final state directly.

Testing a Component with Vuex and Actions

The SearchInput component is affected. Why? Because we enter text in the text input field. When that happens, the “search” action is dispatched which then calls the “findWords” and so on. So API is called, we need to mock it. Let’s see what has changed.

import Dictionary from '@/services/Dictionary';

jest.mock('@/services/Dictionary');
beforeEach(() => {
  jest.clearAllMocks();
});

So just like before, we import the module that we mock. Now let’s see the first test and how it’s changed.

test('Checks if inputting values works', async () => {
    const { store, wrapper } = factory();
    const mockData1 = [
      {
        id: 6949,
        simplified: '你好',
        traditional: '你好',
        pinyin_num: 'ni3 hao3',
        pinyin_accent: 'nǐ hǎo',
        pinyin_clean: 'ni hao',
        pinyin_no_spaces: 'nihao',
        also_written: '',
        classifiers: '',
        definitions: 'hello; hi',
        frequency: 6639327,
      },
    ];
    const mockData2 = [
      {
        id: 40809,
        simplified: '我们',
        traditional: '我們',
        pinyin_num: 'wo3 men5',
        pinyin_accent: 'wǒ men',
        pinyin_clean: 'wo men',
        pinyin_no_spaces: 'women',
        also_written: '',
        classifiers: '',
        definitions: 'we; us; ourselves; our',
        frequency: 283794,
      },
    ];
    const mockData3 = [
      {
        id: 71685,
        simplified: '的',
        traditional: '的',
        pinyin_num: 'de5',
        pinyin_accent: 'de',
        pinyin_clean: 'de',
        pinyin_no_spaces: 'de',
        also_written: '',
        classifiers: '',
        definitions: "of; ~'s (possessive particle); (used after an attribute); (used to form a nominal expression); (used at the end of a declarative sentence for emphasis)",
        frequency: 4540297,
      },
    ];
    const mockData4 = [
      {
        id: 51254,
        simplified: '朋友',
        traditional: '朋友',
        pinyin_num: 'peng2 you5',
        pinyin_accent: 'péng you',
        pinyin_clean: 'peng you',
        pinyin_no_spaces: 'pengyou',
        also_written: '',
        classifiers: '个; 位',
        definitions: 'friend',
        frequency: 39168,
      },
    ];
    Dictionary.getWord.mockResolvedValueOnce({ data: mockData1 })
      .mockResolvedValueOnce({ data: mockData2 })
      .mockResolvedValueOnce({ data: mockData3 })
      .mockResolvedValueOnce({ data: mockData4 });

    // 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: '你好' });
    // now that will dispatch the searchAction
    // let's wait for the actions to happen
    await flushPromises();

    expect(Dictionary.getWord).toHaveBeenCalledTimes(1);
    expect(wrapper.find('[data-test="searchString"]').exists()).toBe(true);
    const searchString = wrapper.find(
      '[data-test="searchString"]',
    ).element.textContent;
    expect(searchString).toBe('你好');
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('你好');

    // let's also "write some text" in the textfield using setValue
    await wrapper.find('[data-test="searchBox"]').setValue('我们的朋友');
    // let's wait for the actions to happen
    await flushPromises();
    expect(Dictionary.getWord).toHaveBeenCalledTimes(4);
    const updatedSearchString = wrapper.find('[data-test="searchString"]').element.textContent;
    expect(updatedSearchString).toBe('我们的朋友');
    // let's check the store state as well (obviously should pass if above passes)
    expect(store.state.search.searchString).toBe('我们的朋友');
  });

So like before, I have the data in there. (You might ask why I am actually putting the data in there rather than an empty object (which techinically would work) but soon when we add more components we will test it with the actual data. At that point I will also put all this data into some file we can import as a fixture (is that the term in JS?)). Then I mock the getWord function enough times (I know how many times it will be called). I then setData but then what’s that flushPromises? To be honest, it’s not exactly necessary in the first test. That’s because we are already awaiting setData which dispatches the search action which only has 1 word segmented which then dispatches the findWords action only once which hits the API server only once. IF, however, we had multiple async functions being called, the test would fail. For example, when we set the value to ‘我们的朋友’, that dispatches the search action 1 time, which dispatches the findWords function 1 times, which then calls the API (which is asynchronous) 3 times. Consequently, we need to wait for all of them to resolve. So we use await flushPromises() for that and magic, it works. And that’s enough testing for today. I have more tests in there. Please look at them, I’ve made lots of changes.

The commit for this post is here.

In the next post we will make the SearchResults pretty. Then we will add the Word page view component.