DEV Community

loading...

Vue TDD by example: create Todo app

Jur de Vries
I am an independent software engineer and musician/artist. That makes me a coding artist. I believe in the software craftsmanship movement.
Originally published at thecodingartist.com ・11 min read

Vue TDD by example: create Todo app.

This blog will be the first in a series about Test Driven Development in VueJs.
I will follow the steps of test driven development:

  • Create a failing test
  • Make it pass in the most simple way
  • Refactor
  • Continue with adding a new failing test

If you are new to TDD it might feel weird to do all the tiny steps, but I advise you to follow along to get a grasp of it.

Prerequisites

In order to follow this tutorial you need the following:

Step 0: setting the stage

Before we can do anything, we need to create a new empty Vue project. In order to do that we use the Vue cli:

vue create vue-tdd-todo

Now choose 'manually select features' and check the following:

  • Babel
  • Linter
  • Unit testing

Then for the linter choose 'ESLint + standard config'. The rest of the lint features is of your own choice.
For unit testing choose:

  • Jest

And for placing config choose 'In dedicated config files'. Now npm should be installing all the code.

For css we will use tailwindcss, so the page we are about to create is not plain old ugly html (although I have absolutely no problem with that...).

npm install -D tailwindcss

And then

// postcss.config.js
const autoprefixer = require('autoprefixer');
const tailwindcss = require('tailwindcss');

module.exports = {
  plugins: [
    tailwindcss,
    autoprefixer,
  ],
};

Now we need to import Tailwind CSS into our project (I also added a base style for h1).

/* src/assets/styles/base.css */
@tailwind base;

h1 {
  @apply text-2xl font-bold;
}

@tailwind components;
@tailwind utilities;
// src/main.js
import Vue from 'vue'
import App from './App.vue'

import './assets/styles/base.css'

Vue.config.productionTip = false

new Vue({
  render: h => h(App)
}).$mount('#app')

We are ready to add some tailwind styles in the future when needed. From now on I will not elaborate on the styles that I set to the various elements, as that is not the aim of this tutorial.

Let's empty the existing content of App.vue so we only have a title "Todo".

<template>
  <div id="app" class="container">
    <h1>Todo</h1>
  </div>
</template>

<script>

export default {
  name: 'App'
}
</script>

<style>
#app {
  @apply mx-auto text-center;
}

h1 {
  @apply text-2xl font-bold;
}
</style>

As a last thing, we delete unneeded files the Vue cli added:

  • src/assets/logo.png
  • src/components/HelloWorld.vue
  • tests/unit/example.spec.js

Te code can be found on github.

Step 1: The component

The aim is to create a component in which we can add and check todo items. Following the rules of TDD the first test that we should write is a test that forces us to create the component.

// tests/components/Todo.spec.js
import { shallowMount } from '@vue/test-utils'
import Todo from '@/components/Todo'

describe('The Todo.vue component', () => {
  it('Can be mounted', () => {
    const wrapper = shallowMount(Todo)
    expect(wrapper.exists()).toBeTruthy()
  })
})

Now run the test using npm run test:unit or use your IDE by creating a run configuration for the tests.

And it fails. That's good news, because now we know the test is actually working. If it passed the test would not have worked correctly. Now we need to create the component.

<template>
  <div>
    <h2>My List</h2>
  </div>
</template>

<script>
export default {
  name: 'Todo'
}
</script>

If we import this component in our test, the test passes. Success! Now we can proceed to the next test.
Let's make the displayed title in the component a prop. The test for this would be:

  it('Displays the title when passed as a prop', () => {
    const wrapper = shallowMount(Todo, {
      propsData: {
        title: 'A random title'
      }
    })
    expect(wrapper.text()).toMatch('A random title')
  })

Notice the title "A random title". I put these words to indicate to the reader of the test that the title is really random and not a 'magic' string. A well written test serves as documentation for your component as well so always strive for clearness.

And of course the test fails if we run it. Let's make it pass.

<template>
  <div>
    <h2>A random title</h2>
  </div>
</template>

<script>
export default {
  name: 'Todo'
}
</script>

And it passes. But wait! That's complete bullshit! The title is hardcoded! Well, that's the idea of TDD. Make the test pass in the most easy and degenerate way, and that's exactly what we did here. In this case it might be a little artificial but when you create a difficult algoritm it can really help you.

Now let's refactor. In the code there is not much to refactor right now, but in the tests there is: the first test is redundant due to the fact that in the second test we successfully mounted the component. So we can delete the first test.

By writing the next test we should get rid of the hardcoded title. How about setting a different title?

  it('Displays the second title when passed as a prop', () => {
    const wrapper = shallowMount(Todo, {
      propsData: {
        title: 'Another random one'
      }
    })
    expect(wrapper.text()).toMatch('Another random one')
  })

Now we really have to start implementing it the right way so let's do that.

<template>
  <div>
    <h2>{{ title }}</h2>
  </div>
</template>

<script>
export default {
  name: 'Todo',

  props: {
    title: {
      type: String,
      required: true
    }
  }
}
</script>

And the tests pass... We have 2 tests for the same functionality though. Maybe just put them together?

  it('Displays the title when passed as a prop', () => {
    const wrapper = shallowMount(Todo, {
      propsData: {
        title: 'A random title'
      }
    })
    expect(wrapper.text()).toMatch('A random title')
    const wrapper2 = shallowMount(Todo, {
      propsData: {
        title: 'Another random one'
      }
    })
    expect(wrapper2.text()).toMatch('Another random one')
  })

There is a rule in TDD which states that there should only be one assert in each test and this test seems to violate this. But think again: are we really asserting two times here, or could these assertions be regarded as one?
As you can see refactoring both the code and the tests is an important part of the process.

Now the basic component is ready, we can add it in the App.vue file, so we can actually see something:

<template>
  <div id="app" class="container">
    <h1>Todo</h1>
    <Todo title="My List"/>
  </div>
</template>

<script>
import Todo from '@/components/Todo'

export default {
  name: 'App',

  components: {
    Todo
  }
}
</script>

Te code can be found at: github

Step 2: adding an item

The next thing to do is to enable the creation of todo items. In order to do that we need an input element and a submit button. Again we write the test first. For selecting elements we make use of a special data-attribute: data-testid. We will also check for only one todo item. Notice the async await, because we have to wait for the click to be done.

  it('allows for adding one todo item', async () => {
    const wrapper = shallowMount(Todo, {
      propsData: {
        title: 'My list'
      }
    })
    wrapper.find('[data-testid="todo-input"]').setValue('My first todo item')
    await wrapper.find('[data-testid="todo-submit"]').trigger('click')
    expect(wrapper.find('[data-testid="todos"]').text()).toContain('My first todo item')
  })

And of course it fails, so let's try to implement it.

<template>
  <div>
    <h2>{{ title }}</h2>
    <input type="text" data-testid="todo-input" v-model="newTodo">
    <button data-testid="todo-submit" @click.prevent="addTodo">Add</button>
    <div data-testid="todos">
      {{ todos }}
    </div>
  </div>
</template>

<script>
export default {
  name: 'Todo',

  props: {
    title: {
      type: String,
      required: true
    }
  },

  data () {
    return {
      todos: '',
      newTodo: ''
    }
  },

  methods: {
    addTodo () {
      this.todos = this.newTodo
    }
  }

}
</script>

This passes but of course its plain ugly. There is not even an array of todos! Just a string. Remember that the idea of TDD is that you first focus on making it work, than on making it right. So how do we make it right? By writing another test that forces us to transform the todos into an array.

  it('allows for more than one todo item to be added', async () => {
    const wrapper = shallowMount(Todo, {
      propsData: {
        title: 'My list'
      }
    })
    wrapper.find('[data-testid="todo-input"]').setValue('My first todo item')
    await wrapper.find('[data-testid="todo-submit"]').trigger('click')
    wrapper.find('[data-testid="todo-input"]').setValue('My second todo item')
    await wrapper.find('[data-testid="todo-submit"]').trigger('click')
    expect(wrapper.find('[data-testid="todos"]').text()).toContain('My first todo item')
    expect(wrapper.find('[data-testid="todos"]').text()).toContain('My second todo item')
  })

Which we can implement by:

<template>
  <div>
    <h2>{{ title }}</h2>
    <input type="text" data-testid="todo-input" v-model="newTodo">
    <button data-testid="todo-submit" @click.prevent="addTodo">Add</button>
    <div data-testid="todos">
      <div v-for="(todo, todoKey) of todos" :key="todoKey">
        {{ todo }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Todo',

  props: {
    title: {
      type: String,
      required: true
    }
  },

  data () {
    return {
      todos: [],
      newTodo: ''
    }
  },

  methods: {
    addTodo () {
      this.todos.push(this.newTodo)
    }
  }

}
</script>

As you can see there is only a small change from a single value to an array value. The transformation is really simple! If we look at the tests however we notice that there is duplication in the code. It is also not directly clear what is happening. So let's refactor the tests (we can do that because we have working code which can be used to test the tests!).
The refactorings I want to do are:

  • Put the add todo tests in it's own test suite.
  • Extract a method to add a todo.
  • Extract a method to find element text.
describe('adding todo items', () => {
    let wrapper

    beforeEach(() => {
      wrapper = shallowMount(Todo, {
        propsData: {
          title: 'My list'
        }
      })
    })

    async function addTodo (todoText) {
      wrapper.find('[data-testid="todo-input"]').setValue(todoText)
      await wrapper.find('[data-testid="todo-submit"]').trigger('click')
    }

    function elementText (testId) {
      return wrapper.find(`[data-testid="${testId}"]`).text()
    }

    it('allows for adding one todo item', async () => {
      await addTodo('My first todo item')
      expect(elementText('todos')).toContain('My first todo item')
    })
    it('allows for more than one todo item to be added', async () => {
      await addTodo('My first todo item')
      await addTodo('My second todo item')
      expect(elementText('todos')).toContain('My first todo item')
      expect(elementText('todos')).toContain('My second todo item')
    })
  })

That's a lot better. The tests read well, and we got rid of the duplication. Now let's have a look into the rendered items. We didn't do that till now (there was no need!), but it is advisable to do it from time to time. What we see is that there are some styling issues (it looks ugly) and after adding a todo, the text the input is not cleared when a todo has been added. You can add any style you want or even change the element types (that's wy we used the 'data-testid' attribute!). Notice how the styling has no influence on the tests at all!
We will solve the emptying of the input element by first writing the test.

  it('empties the input field when todo has been added', async () => {
    await addTodo('This is not important')
    expect(wrapper.find('[data-testid="todo-input"]').element.value).toEqual('')
  })

Of course it fails, so let's fix it:

  methods: {
    addTodo () {
      this.todos.push(this.newTodo)
      this.newTodo = ''
    }
  }

As you might notice now is that the writing of a test and the corresponding production code take only a few minutes to write. The cycles are very short. This is the idea of TDD. A red, green refactor cycle should be very short.

Before continuing with marking the todo's as done, there is one test to write: we want the todo items to be displayed in the exact order we entered them, so let's make the test:

    it('displays the items in the order they are entered', async () => {
      await addTodo('First')
      await addTodo('Second')
      expect(elementText('todo-0')).toMatch('First')
      expect(elementText('todo-1')).toMatch('Second')
    })

This only fails because we didn't add the test id's, so let's fix that in the code:

    ...
    <ul data-testid="todos" class="text-left">
      <li
          v-for="(todo, todoKey) of todos"
          :data-testid="`todo-${todoKey}`"
          :key="todoKey"
      >
        {{ todo }}
      </li>
    </ul>
    ...

Te code can be found on github.

Step 3: marking items done

A todo list is useless if we can't mark an item done, so we need an element we can click for each todo item which sets the item to done.

    it('items can be marked as done by clicking an element before the item.', async () => {
      await addTodo('First')
      await addTodo('Second')
      expect(wrapper.find('[data-testid="todo-0-toggle"]').text()).toEqual('Mark done')
      await wrapper.find('[data-testid="todo-0-toggle"]').trigger('click')
      expect(wrapper.find('[data-testid="todo-0-toggle"]').text()).toEqual('Done')
    })

Of course this fails. There is quite a lot to be done in order to get this working: the todos are now stored as a flat list of strings. The most easy way to store the status of the item is to transform the items into objects where we can store the status. Let's do that first.

<template>
    ...
      <li
          v-for="(todo, todoKey) of todos"
          :data-testid="`todo-${todoKey}`"
          :key="todoKey"
      >
        {{ todo.description }}
      </li>
    ...
</template>

<script>
export default {
  ...
  methods: {
    addTodo () {
      this.todos.push({
        description: this.newTodo,
        done: false
      })
      this.newTodo = ''
    }
  }
  ...
}
</script>

Now still the only the last test fails, but we where able to do a quick refactor to allow for setting the item to done. All the earlier tests still succeed so we can be confident the code is still working as expected. Now let's proceed fixing the last test.

<template>
  ...
      <li
          v-for="(todo, todoKey) of todos"
          :data-testid="`todo-${todoKey}`"
          :key="todoKey"
      >
        <span
            :data-testid="`todo-${todoKey}-toggle`"
            @click.prevent="toggle(todo)"
        > {{ todo.done ? "Done" : "Mark done" }}</span>
        {{ todo.description }}
      </li>
  ...
</template>

<script>
export default {
  ...
  methods: {
    ...
    toggle (todo) {
      todo.done = !todo.done
    }
  }
  ...
}
</script>

That was quite easy again. There is a problem though: we check if the todo item is done by looking at the text in the specific element. But what if we want to change the text of this element? Would it not be better to check data in the component?

NO! NEVER TEST IMPLEMENTATION DETAILS!!!!!

The way we implemented if an item is done is an implementation detail we might want to refactor later. This is the whole point of unit tests: you can alter the implementation as long as the public interface stays the same. The unit test is only testing the public interface. When developing Vue, the public interface or output of a component is the rendered html. It might also be a call to a service, or a call to the vuex store, but in this case the only public interface is the template.

The text we check however is also an implementation detail: it has to do with how we display the data and we might want to change that. So lets refactor the way we check if a todo item has been done:

it('items can be marked as done by clicking an element before the item.', async () => {

      function itemIsDone (itemId) {
        return wrapper.find(`[data-testid="todo-${itemId}"]`).attributes('data-done') === 'true'
      }

      await addTodo('First')
      await addTodo('Second')

      expect(itemIsDone(0)).toBe(false)
      await wrapper.find('[data-testid="todo-0-toggle"]').trigger('click')
      expect(itemIsDone(0)).toBe(true)
    })

As you can see I moved the functionality to check if an item is done to a separate function and changed that functionality. The test is easy to read now and by looking at the function the definition when the item is done is easily understandable. This way the test is also more a way to document the functionality. By writing the status of the todo to a data attribute, it is now far easier to change the rendering.

The fixed code in the template looks like this:

<template>
  ...
      <li
          v-for="(todo, todoKey) of todos"
          :data-testid="`todo-${todoKey}`"
          :data-done="todo.done"
          ...
      >
        ...
      </li>
    ...
</template>

As a last step I added some styling without altering any of the functionality.

Te code can be found on github.

And this ends this tutorial. I hope you learned something.

Discussion (0)