english, Software Development, web engineering

Harry Potter and the JavaScript Fatigue  –  Part 2

This is the second part of a series of articles on Vue.JS frontend development. In case you missed it, here is part 1: The philosopher's UI.

You can pull the App from here! If you do so, run git checkout part2 for this part.

Part 2: The Chamber of Storage

You have a frontend that allows viewing of a static list of Todos. Now you need something to store and change all your magical tasks! You imagine a chamber of secrets. But after some consideration you decide to call it a storage of state, or shorter: store. As it turns out these things already exist (what a relief). You could use Vue.js directly to manipulate state but there is also Vuex.

hogwarts-rot

Store Skeleton

You install it via yarn add vuex and put it into use:

import Vue from 'vue'
Vue.config.productionTip = false

import App from './App'
import store from './store';

new Vue({
  el: '#app',
  store,
  render: h => h(App)
});


with a store that just contains two tasks:
 

import * as Vuex from 'vuex';
import Vue from 'vue';

import {Task} from "@/domain/Task";

export const initialState = () => {
  return {
    tasks: [new Task("new task"), new Task("old task")]
  }
};

export const getters = {};
export const mutations = {};
export const actions = {};

Vue.use(Vuex);
export default new Vuex.Store({
  state: initialState(),
  getters,
  mutations,
  actions
});


Unit testing user workflows

Now this was easy, but does not solve your problem yet. You need a way to enter the title text of a new task. An input tag should be visible and on hitting enter it should call an Action that in turn mutates the state. You start by writing a test for a new Vue component with an input field.

import NewTaskInput from '../../src/components/NewTaskInput'

describe('NewTaskInput.vue', () => {
  it('renders an input field', () => {
    const wrapper = shallow(NewTaskInput);
    expect(wrapper.element).toMatchSnapshot();
    expect(wrapper.find('input')).toBeDefined();
  });
});

this test asserts that there is an input field. The component may look like:

</template>
 <input class="new-todo"
autofocus autocomplete="off"
placeholder="What needs to be done?">
</template>
<script lang="ts" > export default { name: 'NewTaskInput', data() { return { } } } </script>

This is a component that renders an input field with no functionality at all. You want it to add a task to the store once you hit enter. You can test it by simulating a keyup.enter event and verifying that it calls a method called addTask

 

describe('add task', () => {
  it('calls add task on key enter', () => {
    const wrapper = mount(NewTaskInput);
    const addTask = jest.fn();
    wrapper.setMethods({addTask});
    wrapper.find('input').trigger('keyup.enter');
    expect(addTask).toHaveBeenCalled();
  });
});

To make this test work only two very small changes to the component are necessary.

  1. add @keyup.enter="addTask" to the input field
  2. add a addTask() {} method to the methods block of the component

Ok, this calls a method on the right key press. But you did not specify what the method should do! So you define that it should dispatch an Action to add a task with the given title.

import {Actions} from "../../src/store";
//...
describe('add task', () => {
  it('dispatches AddTask action to store', () => {
    const context = { $store: { dispatch: jest.fn() }, taskTitle: "title" };
    NewTaskInput.methods.addTask.bind(context)();
    expect(context.$store.dispatch).toHaveBeenCalledWith({
      type: Actions.AddTask,
      title: "title"
    });
  });
});


This test assumes that the field taskTitle contains the input fields value. But wait a minute, that is not what you want to test! You want to capture the behaviour of the component and not it's implementation. A perfect test would

  1. set the input fields value to ‘title’
  2. trigger a ‘keyup.enter’ event
  3. check that the correct value has been passed to the store

After some experimentation you finally come up with the following test:

import store from '../../src/store'
// ...
it('adds task from input field, if enter key is pressed', (done) => {
  const spy = jest.spyOn(store, 'dispatch'); // 0
  const givenValue = "A very special value";

  const wrapper = mount(NewTaskInput, { store }); 
  const input = wrapper.find('input');
  input.element.value = givenValue; // 1
  input.trigger('input');           // 2
  input.trigger('keyup.enter');     // 3
  wrapper.vm.$nextTick(() => { // 4
	  expect(spy).toHaveBeenCalledWith({
	    type: Actions.AddTask,
	    title: givenValue
	  });
	  done(); // 5
  });
});

So you spy on the actual store (0). Then you set fill the input field with a test valuer (1). To get that value into the components state you trigger an input event. You simulate the browser at this point. Then you trigger the key press of the enter button (3). And finally check that the correct Action triggered in the store.

In step (4) you give Vue.js a tick to sync with the values in the input field. If the implementation does not use watchers this might not be necessary. But better be safe than sorry! And after asserting that the component called the store, you have to report to jest that the test finished (5).

This test can replace all the others. While developing a component it is OK to test implementation details. But after finishing the component, your tests must only cover the intended behaviour. Internal function calls and variables may change and then all your tests break!

Anyway, here is the full component:

<template>
  <input class="new-todo"
         autofocus autocomplete="off"
         placeholder="What needs to be done?"
         v-model="taskTitle"
         @keyup.enter="addTask">
</template>

<script lang="ts">
  import {Actions} from "../store/actions";
  export default {
    name: 'NewTaskInput',
    data() {
      return { taskTitle: "" }
    },
    methods: {
      addTask() {
        const value = this.taskTitle && this.taskTitle.trim();
        this.$store.dispatch({
          type: Actions.AddTask,
          title: value
        })
      }
    }
  }
</script> 


Testing store interaction

Up until now, you have not implemented the AddTask Action. And we ignored that this resulted in a console error:

console.error node_modules/vuex/dist/vuex.common.js:419
  [vuex] unknown action type: addTask

It did not fail your tests. At least it did not fail mine. But this is the next thing that you need to put in place.

First you need something to change the state. In Vuex calls this a mutation. A mutation is very easy to test, as it is a function that changes state with no strings attached.

describe("store task handling", () => {
 describe("mutations", () => {
   it("adds tasks", () => {
     const state = { tasks: [] };
     mutations.taskAdded(state, {
       type: Mutations.TaskAdded,
       task: new Task("task1")
     });
     expect(state.tasks.length).toBe(1);
   });
 });
});

this is pretty easy to implement also

export enum Mutations {
  TaskAdded = 'taskAdded'
}

export interface TaskAdded {
  type: Mutations.TaskAdded;
  task: Task;
}

export const mutations = {
  [Mutations.TaskAdded] (state, payload: TaskAdded) {
    state.tasks.push(payload.task);
  }
};

The Mutations name is in the Past Tense. This emphasises that it describes an event that has already happened. The idea is that this event is not reflected in the state yet. This also means that there must not be any circumstance in which a Mutation might fail! A Mutation might be TaskAdded but also TaskAddedFailed. The second mutation describes a failed attempt. But the mutation itself must not fail!

The next thing you want in a store is a predefined function to fetch a task by title.

describe("getters", () => {
  it("finds tasks by title", () => {
    const task1 = new Task("task1");
    const state = { tasks: [task1] };
    expect(getters.taskByTitle(state, getters)("task1")).toBe(task1);
    expect(getters.taskByTitle(state, getters)("task2")).toBeUndefined();
  })
});
export const getters = {
  taskByTitle: (state, getters) => (title) => {
    return state.tasks.find(task => task.title === title)
  }
};

Testing this getter was easy, but getters might very well refer to each other. Consider these two getters:

tasksOrderedByTitle: (state, getters) => {
  return _.orderBy(state.tasks, ['title'], ['asc']);
},

firstTitle: (state, getters) => {
  return _.first(getters.tasksOrderedByTitle())
}

firstTitle returns the first task of the tasks ordered by title (this code uses lodash). In such a case it makes sense to mock the dependencies

const taskA = new Task("id", "taskA");
const taskB = new Task("id", "taskB");
getters.tasksOrderedByTitle = jest.fn().mockReturnValueOnce([taskA, taskB]);
expect(getters.lowestTitle({}, getters)).toEqual(taskA);

On first glance, testing actions is much harder than getters or mutations. Actions have much more dependencies and their effects are function calls. It feels easier to check modifications of the state. They cannot change the sate at all! That also means that you do not have to think about how the state changes! It is valid to mock the context (state, getters) and spy on the commit function. You only define a constant state and mock away the getters. Then assert that the action calls the right functions. The only thing that might get complex is that Actions may do all kind of messy asynchronous Api calls (And we will look at that in Part 3 of the series)

describe("actions", () => {
  const mockContext = (foundTask: Task) => {
    return {
      getters: {  taskByTitle:(s) => foundTask },
      commit: jest.fn()
    };
  };

  it("adds tasks if they cannot be found", () => {
    const context = mockContext(undefined);
    const taskTitle = "title";

    actions.addTask(context, {
      type: Actions.AddTask,
      title: taskTitle
    });

    expect(context.commit).toHaveBeenCalledWith({
      type:  Mutations.TaskAdded,
      task: new Task(taskTitle)
    });
  });

  it("does not add tasks if they can be found", () => {
    const taskTitle = "title";
    const context = mockContext(new Task(taskTitle));

    actions.addTask(context, {
      type: Actions.AddTask,
      title: taskTitle
    });
    expect(context.commit).toHaveBeenCalledTimes(0);
  });
});

Spying on a function is a very powerful tool to verify a lot of interaction patterns. The above code says that it only adds a task if it cannot find it by title. And using the getter you implemented already, this function is

[Actions.AddTask] ({getters, commit}, payload: AddTask) {
  if(_.isNil(getters.taskByTitle(payload.title))) {
    commit({
      type: Mutations.TaskAdded,
      task: new Task(payload.title)
    });
  }
},

A scalable future

For today you find yourself satisfied by the result. You can view and add tasks! The next thing you want to do is to change the state of your Tasks.

You sketch some types for the Task State logic:

export enum TaskState {
  Open,
  Done
}

export class Task {
  title: string;
  state: TaskState;

  constructor(title: string) {
    this.title = title;

    this.state = TaskState.Open;
  }
}

Now with these actions and mutations you should be able to implement this in a test-driven way.

export enum Actions {
  AddTask = 'addTask',
  SetTaskComplete = 'setTaskComplete'
}

export interface SetTaskComplete {
  type: Actions.SetTaskComplete;
  title: string;
  complete: boolean;
}

export enum Mutations {
  TaskAdded = 'taskAdded',
  TaskStateChanged = 'taskStateChanged'
}

export interface TaskStateChanged {
  type: Mutations.TaskStateChanged;
  task: Task;
  newTaskState: TaskState;
}

You realise how well this approach scales. If you could only convince the Weasleys to learn Typescript to help you out with the new features. But this is something for another day at Hogwarts!

Get ready for the final Part 3 and read "The Prisoner of Api" tomorrow.
    
About Maximilian Schuler

I am working at itemis as a software engineer at the web engineering team in Hamburg. As a full-stack developer I am at home in all domains of web development: from client-side development over data-intensive backend applications to platform engineering.