Web CNLearn - Vue Frontend 7 - Creating Our WordView View Component and Testing Vue Router

We’re gonna add another “page” to our application today??? Finally.

We use the search function, find words, but then we’d like to get more information. How should we do that? Some modal? (no, I don’t like when they are used to display information). A new page? ok you convinced me.

Let’s first think of how it’s gonna work and then we will implemented that. When we click on a word, we want to be taken to a page. For now, it will only display the simplified, pinyin, and the list of definitons. We need a new router route and a component for it. We are also gonna pass some props (the data associated with each word). Actually, no. No props will be passed.

So what will be the address of that route? /words/:simplified where simplified is the word we are interested in. In our router/index.js file, let’s add another route:

...
import WordPage from '@/views/WordPage.vue'

const routes = [
  {
    path: '/',
    name: 'Search',
    component: Search,
  },
  {
    path: '/words/:simplified',
    name: 'wordDetail',
    component: WordPage,
  },
];

...

Note that we are using the WordPage component to display it. Let’s have a look at it.

<template>
  <div>
      <p>Welcome to the word page for {{ simplified }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      simplified: '',
    };
  },
  created() {
    this.simplified = this.$route.params.simplified;
  },
};
</script>

<style>

</style>

It has a simplified data property. Then, when the WordPage instance is created, we use the created Lifecycle hook to extract the query params in our component and set our simplified data property equal to it. Then, we display the simplified property in the template. So far, just testing that everything is working correctly.

Word Page Template

Ok let’s think of what we’d like to see. For now, let’s just display the character(s) and the pinyin as well as a list of definitions. Everything will be in the WordPage view component for now but later we will extract some of that into their own components as they will be reused in other parts of the application. Before we can do that, however, we need to get the word object containing all the information from our state. How will we do that? In the (previous post, we used a getter to get the information and display it on the cards. Let’s do that here too. Let’s use the mapGetters helper in our computed

  computed: {
    ...mapGetters('search', ['getWord']),
  },

Then we have to think: do we use just use getWord(this.simplified) in our template and then take it from there? No, let’s make it nicer. Let’s get the information from the store when the component is created, i.e. in our created() life cycle hook. So what’s the flow?

  1. A router-link to the page is clicked.
  2. We get the information from the store.
  3. We display that information. Let’s code that.
<script>
export default {
  data() {
    return {
      simplified: '',
      loading: false,
      word: null,
    };
  },
  created() {
    this.loading = true;
    this.simplified = this.$route.params.simplified;
    this.word = this.getWord(this.simplified);
    this.loading = false;
  },
  computed: {
    ...mapGetters('search', ['getWord']),
  },
};
</script>

Ok so I’ve added a few more things. First of all, our component has a few properties: simplified, loading (to indicate whether data is loading or not), and word (an object that will contain all the information). When the component is created, it sets the loading state to true. Then it extracts the simplified parameters from the query parameters from Vue router. It then sets the word property of our component equal to the object it gets from the store. Then, it sets the loading status to False. Let’s see how we use that this.word data in our template.

<template>
  <div class="container">
    <div v-if="this.loading">
    <p>Loading...Be patient.</p>
    </div>
    <div v-else>
      <div v-for="option in word" :key="option.id">
        <h2>{{ option.simplified }} {{ option.pinyin_accent }}</h2>
        <div id="definitions">
          <h2>Definitions</h2>
          <ol class="definitions">
            <li
              v-for="(definition, index) in option.definitions.split(';')"
              :key="index"
            >
              {{ definition }}
            </li>
          </ol>
        </div>
      </div>
    </div>

  </div>
</template>

Ok so initially, it shows the Loading…Be patient. message. Then, once the logic in created() finishes, loading is false and we show the data. Why do I have a v-for though? Well, remember that certain words have multiple pronunciations and so they appear in separate rows in the database. In our state, however, in cachedWords (a Map), the key is the simplified character. Consequently, the value in our map is an array of objects. That’s why we have a v-for. Ok so for each “option” in the word object, we show its simplified version and its pinyin_accent. Then, lower, we also display all the definitions with, you guessed it, a v-for. This time we create an ordered list with the various definitions available. Note that by default, the “definitions” property in the option object is a string with the various definitions split by “;”. I use the split method, have an array, use v-for to iterate over it. All good. If someone clicks on a search result and they’re taken to the WordPage view component, it should work. But what if they refresh the page? Uh oh, not good. Why’s that? Well, the app state will be cleared, and nothing will show. Do we make the user go back to the search page, search again, click on it again? Let’s not do that.

What should we do? Well, if they are on the page and refresh, let’s use the findWords action to look for the word. How should we do that? let’s change our “script” part of the component to:

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  data() {
    return {
      simplified: '',
      loading: false,
      word: null,
      characters: new Set(),
    };
  },
  async created() {
    this.loading = true;
    this.simplified = this.$route.params.simplified;
    if (!this.getWord(this.simplified)) {
      await this.findWords([this.simplified]);
    }
    this.word = this.getWord(this.simplified);
    [...this.simplified].forEach((c) => this.characters.add(c));
    this.loading = false;
  },
  methods: {
    ...mapActions('search', ['findWords']),
  },
  computed: {
    ...mapGetters('search', ['getWord']),
  },
};
</script>

What happens now? When the component is created, it first checks if the word is in the cachedWords Map using the getWord getter. If not, it dispatches the getWord action sending in this.simplified as a one element array. Please note the use of async and await. We need that. Once that happens, we the use the getWord getter again to extract the information. I am also extracting the component characters (as we will use that in a bit). Finally, we set the loading to false.

Ok but what if the user manually goes to WEBSITE/words/你们是. “你们是” is not a word, it’s a sentence, so there wouldn’t be such a word in the dictionary. So nothing should be shown on the page. What do we do??? If we dispatch the findWords action, it won’t find it. And then it will call itself recursively with the [...word] as argument, meaning it will call itself with the [“你”, “们”, “是”] array. The WordPage will still display “No such word exists…“but if the user then clicks the Home/Search link, they will see the words that are found. Ok. Fair enough.

BUT, what if….someone goes to WEBSITE/words/hello. I’ll tell you what happens:

  1. Hello is the simplified parameter
  2. The findWords action gets dispatched with [“hello”]. Nothing is found.
  3. The findWords action is then recursively called with [“h”, “e”, “l”, “l”, “o”].
  4. The API returns an empty array for “h”. Since the length is zero, the action gets dispatched again with […“h”].
  5. Step 4 happens again.
  6. Step 4 happens again.
  7. Step 4 happens again.
  8. Step 4 happens again.
  9. Step 4 happens again. …
  10. Step 4 happens again.

Ok I think you got the point. So how do we fix it? Well, let’s extract only the chinese characters from the route’s params and then only dispatch the action if the string is not empty. Otherwise, show the “not found message”.

  async created() {
    this.loading = true;
    const unprocessedSimplified = this.$route.params.simplified;
    this.simplified = extractChineseCharactersToString(unprocessedSimplified);
    if (this.simplified !== '' && !this.getWord(this.simplified)) {
      await this.findWords([this.simplified]);
    }
    this.word = this.getWord(this.simplified);
    [...this.simplified].forEach((c) => this.characters.add(c));
    this.loading = false;
  },

And I think we’ll stop here in this post. Jk, obviously we are going to write some TESTS! yay.

Ok let’s think of the various things to test:

  1. Test a page with only one meaning per word
  2. Test a page with several meanings for a specific word
  3. Test a page with an unknown (Chinese characters) word
  4. Test a page with a non-Chinese word.

We are also using Vue-Router so we will have to take that into account in our tests. Also keep in mind that for now we are essentially testing the WordPage view component. In the next post we will add a few more things, it will get messy, we will refactor. We will change tests. BUT, let’s test nonetheless.

Test with one meaning per word

We will have two tests. One where the word is in cache and one where it has to call the api. For the first one, let’s create a factory function with an initial state.

const initialSearchStore = {
  searchString: '我们',
  segmentedWords: new Map([
    [0, '我们'],
  ]),
  cachedWords: new Map([
    ['我们', [{
      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,
    }]],
  ]),
  currentWords: new Set(['我们']),
};

const factory = () => {
  const store = createStore({
    modules: {
      search,
    },
  });
  const wrapper = mount(WordPage, {
    global: {
      mocks: {
        $route: {
          params: {}
        }
      }
      plugins: [PrimeVue, store],
    },
  });
  return { store, wrapper };
};

Ok so we have a single word in our state which only has one meaning: 我们. Now let’s test how it’s displayed on the WordPage. But before we do that, let’s think of how the WordPage knows which word to display. We are not passing props to it, so we can’t mount the component like that. Instead, it gets the simplified word from the route params.

const unprocessedSimplified = this.$route.params.simplified;

Makes this trickier but let’s see if we can get it to work. So how do we mount it then? We acn mock the route parameter like below:

  const wrapper = mount(WordPage, {
    global: {
      mocks: {
        $route: {
          params: { simplified },
        },
      },
      plugins: [PrimeVue, store],
    },
  });

At the same time, I am also going to include a router in there. For now, I just copy-pasted the router file in my wrapper facctory but in a future post we will also mock that completely. I just don’t want this post to be about that :) So what is our factory function?

const factory = async () => {
  const store = createStore({
    modules: {
      search,
    },
  });
  const routes = [
    {
      path: '/',
      name: 'search',
      component: Search,
    },
    {
      path: '/words/:simplified',
      name: 'wordDetail',
      component: WordPage,
    },
  ];
  const router = createRouter({
    routes,
    history: createWebHistory(),
  });

  // manually set the state of our search module state
  store.state.search = initialSearchStore;
  const simplified = '我们';
  const wrapper = mount(WordPage, {
    global: {
      mocks: {
        $route: {
          params: { simplified },
        },
      },
      plugins: [PrimeVue, store, router],
    },
  });

  return { store, wrapper, router };
};

Like I said, the router is all there for now which is why I also had to import those two components…annoying for now, but I didn’t want to create the fake router just yet but we’ll get there, I promise. Let’s have a look at our test:

Test for WordPage view for a word with one meaning that is present in the state

describe('WordPage', () => {
  test('Checks if the view component got mounted and got the information from the state', async () => {
    const { store, wrapper, router } = await factory();
    await router.isReady();
    // test the header
    const header = wrapper.find('h2');
    expect(header.exists()).toBe(true);
    expect(header.text()).toBe('我们 wǒ men');
    // check the definitions part
    expect(wrapper.find('h3').text()).toBe('Definitions');
    // definitions wrapper elements
    const definitions = store.state.search.cachedWords.get('我们')[0].definitions.split(';');
    const definitionElements = wrapper.findAll('li');
    const defElementsAndDefinitions = zip(definitionElements, definitions);
    defElementsAndDefinitions.forEach(([defElement, definition]) => {
      expect(defElement.text()).toEqual(definition.trim());
    });
  });
});

I call our factory function like always. But what happens next? we await router.isReady(). Why is that? The Vue router we are using has asynchronous routing and we have to make sure it’s ready. Then, we check that we have our header containing the character text and character pinyin. We then check to see if the Definitions heading is there. Finally, we check that every single definition is listed. Ok that was an easy test. Now let’s do the same thing with the same character but this time….the information won’t be in our state :o gasp…we have to mock our Vuex store again. Yup, you didn’t think everything was going to be too easy right?

Test for WordPage view for a word with one meaning that is not present in the state

Ok what if we want to do this test, that means we need a new factory function…or do we? What do you think will happen after we clear the cachedWords after the component is mounted? It won’t actually make a difference since our component already got all the information it needed. It’s like disconnecting your internet after the webpage loaded (as long as you don’t refresh of course). So what do we do? Let’s refactor our factory function so that it takes in an optional initial state. And while we’re at it, let’s also pass in our simplified parameter too. So our factory function now looks like:

const factory = async (searchInitialState, simplified) => {
  const store = createStore({
    modules: {
      search,
    },
  });
  const routes = [
    {
      path: '/',
      name: 'search',
      component: Search,
    },
    {
      path: '/words/:simplified',
      name: 'wordDetail',
      component: WordPage,
    },
  ];
  const router = createRouter({
    routes,
    history: createWebHistory(),
  });

  // manually set the state of our search module state
  store.state.search = searchInitialState;
  const wrapper = mount(WordPage, {
    global: {
      mocks: {
        $route: {
          params: { simplified },
        },
      },
      plugins: [PrimeVue, store, router],
    },
  });

  return { store, wrapper, router };
};

Note that we are passing the initial state of the search module in our Vuex store as an argument as well as the simplified query parameter for Vue router. Ok let’s review our first test:

Refactoring our first test

  test('Checks if the view component got mounted and got the information from the state', async () => {
    const initialSearchStore = {
      searchString: '我们',
      segmentedWords: new Map([
        [0, '我们'],
      ]),
      cachedWords: new Map([
        ['我们', [{
          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,
        }]],
      ]),
      currentWords: new Set(['我们']),
    };    
    const { store, wrapper, router } = await factory(initialSearchStore, '我们');
    await router.isReady();
    // test the header
    const header = wrapper.find('h2');
    expect(header.exists()).toBe(true);
    expect(header.text()).toBe('我们 wǒ men');
    // check the definitions part
    expect(wrapper.find('h3').text()).toBe('Definitions');
    // definitions wrapper elements
    const definitions = store.state.search.cachedWords.get('我们')[0].definitions.split(';');
    const definitionElements = wrapper.findAll('li');
    const defElementsAndDefinitions = zip(definitionElements, definitions);
    defElementsAndDefinitions.forEach(([defElement, definition]) => {
      expect(defElement.text()).toEqual(definition.trim());
    });
  });

ACTUALLY writing the second test

We wanted to test the WordPage when there is a word that exists (with one meaning) but is not in our state. We are still going to test “我们” but this time our API (mocked) will get called. I’m still working on a better name for this test, can someone help?

  test('Checks if the view component got mounted and then dispatched an action to get the necessary data', async () => {
    const initialSearchStore = {
      searchString: '',
      segmentedWords: new Map(),
      cachedWords: new Map(),
      currentWords: new Set(),
    };
    const mockData = [
      {
        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,
      },
    ];
    Dictionary.getWord.mockResolvedValueOnce({ data: mockData });
    const { store, wrapper, router } = await factory(initialSearchStore, '我们');
    await router.isReady();
    // now that will dispatch the findWords action
    // let's wait for the actions to happen
    await flushPromises();

    expect(Dictionary.getWord).toHaveBeenCalledTimes(1);

    // test the header
    const header = wrapper.find('h2');
    expect(header.exists()).toBe(true);
    expect(header.text()).toBe('我们 wǒ men');
    // check the definitions part
    expect(wrapper.find('h3').text()).toBe('Definitions');
    // definitions wrapper elements
    const definitions = store.state.search.cachedWords.get('我们')[0].definitions.split(';');
    const definitionElements = wrapper.findAll('li');
    const defElementsAndDefinitions = zip(definitionElements, definitions);
    defElementsAndDefinitions.forEach(([defElement, definition]) => {
      expect(defElement.text()).toEqual(definition.trim());
    });
  });

What is happening? We have an initial search module state, we have some mockData that the API will return (mockingly?). We then mock the API call once. We mount our component with the simplified parameter. It doesn’t find that in the state so it calls the API. The mock data gets returned. The rest is like in the previous test. YAY it works!

Test for WordPage view for a word that does not exist (and obviously not in the state…)

What will we test this time? We will test a word that does not exist (but that contains Chinese characters). The findWords action will get dispatched for every single one of the individual characters. Then, those characters will be present in cachedWords.


  test('Checks if the view component got mounted and then dispatched an action multiple times', async () => {
    const initialSearchStore = {
      searchString: '',
      segmentedWords: new Map(),
      cachedWords: new Map(),
      currentWords: new Set(),
    };
    const mockData1 = [];
    const mockData2 = 
      [{
        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,
      },
      {
        id: 71686,
        simplified: '的',
        traditional: '的',
        pinyin_num: 'di1',
        pinyin_accent: 'dī',
        pinyin_clean: 'di',
        pinyin_no_spaces: 'di',
        also_written: '',
        classifiers: '',
        definitions: 'see 的士(dī shì)',
        frequency: 4540297,
      },
      {
        id: 71687,
        simplified: '的',
        traditional: '的',
        pinyin_num: 'di2',
        pinyin_accent: 'dí',
        pinyin_clean: 'di',
        pinyin_no_spaces: 'di',
        also_written: '',
        classifiers: '',
        definitions: 'really and truly',
        frequency: 4540297,
      },
      {
        id: 71688,
        simplified: '的',
        traditional: '的',
        pinyin_num: 'di4',
        pinyin_accent: 'dì',
        pinyin_clean: 'di',
        pinyin_no_spaces: 'di',
        also_written: '',
        classifiers: '',
        definitions: 'aim; clear',
        frequency: 4540297,
    }];
    const mockData3 = [{
      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 })
      .mockResolvedValueOnce({ data: mockData3 });
    const { store, wrapper, router } = await factory(initialSearchStore, '的是');
    await router.isReady();
    // now that will dispatch the findWords action
    // let's wait for the actions to happen
    await flushPromises();

    expect(Dictionary.getWord).toHaveBeenCalledTimes(3);

    // test that no word is found
    const noWord = wrapper.find('p');
    expect(noWord.text()).toBe('No such word exists. Please go back to the  search page  and check if any words were found.')
    // check for link back to search page
    const linkToSearch = wrapper.find('a');
    expect(linkToSearch.html()).toBe('<a href=\"/\" class=\"router-link-active router-link-exact-active\" aria-current=\"page\"> search page </a>');
    // check the words that are now present in cachedWords
    expect(store.state.search.cachedWords.get('的')).toEqual(mockData2);
    expect(store.state.search.cachedWords.get('是')).toEqual(mockData3);
  });

Note that we mocked the Dictionary service. We check that it got called 3 times. Once when nothing was found, and twice more for each of the characters present. Since no such word exists, the findWords action was dispatched for 的 and then for 是 respectively. Then, the text telling us nothing was found is shown.

Test for WordPage view for a non-Chinese word that does not exist and is also obviously not in the state and these headers are getting long

Finally, let’s test that if something like /words/hi is opened, the findWords action is not dispatched and nothing is found.


  test('Checks if the view component got mounted and no actions were dispatched', async () => {
    const initialSearchStore = {
      searchString: '',
      segmentedWords: new Map(),
      cachedWords: new Map(),
      currentWords: new Set(),
    };
    const { store, wrapper, router } = await factory(initialSearchStore, 'hi');
    await router.isReady();
    // now that will dispatch the findWords action
    // let's wait for the actions to happen
    await flushPromises();
    // test that no word is found
    const noWord = wrapper.find('p');
    expect(noWord.text()).toBe('No such word exists. Please go back to the  search page  and check if any words were found.')
    // check for link back to search page
    const linkToSearch = wrapper.find('a');
    expect(linkToSearch.html()).toBe('<a href=\"/\" class=\"router-link-active router-link-exact-active\" aria-current=\"page\"> search page </a>');
    // check the words that are now present in cachedWords
  });

And that is it :) Let me lint the code now, push it and…the commit is here.