DEV Community

loading...

Vue TDD by example episode 3: extract components

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 ・10 min read

In the previous episode we added the vuex store to the todo component.
In this episode we are going to finish the refactoring by extracting the form and display components.

We will start where the previous tutorial ended. If you didn't follow the previous episodes, I advise you to do that.

You can find the code to start with on github.

Step 1: extract the 'add todo' form

When extracting a component it is tempting to just copy all the code we have into the new component and then writing/copying the tests afterwards.
This approach can lead to testing implementation however, and it is certainly not test driven. For the specific refactorings we are doing in this episode we also need to test the interaction of the components with the vuex store, which is a new interaction we didn't cover with tests yet. We will first copy and alter the tests and only after we have done that, we will copy the code.

For the 'add todo form', let's first create the test that will force us to write the component.

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

describe('The todo input component', function () {
  it('can be mounted', () => {
    const wrapper = shallowMount(TodoInput)
    expect(wrapper.exists()).toBe(true)
  })
})

And we create the component:

// src/components/TodoInput.vue
<template>
  <div></div>
</template>

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

Now we have the component, let's look in 'todo.spec.js' which test we can copy. I see 3 possible candidates:

    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')
    })
    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('')
    })

In the first 2 tests we check the result of adding a todo using the rendered html. These tests need to be rewritten in order to test the interaction with the vuex store.

Only the last test can be copied as is:

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

describe('The todo input component', function () {
  let wrapper

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

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

Let's fix the test by copying only the code that is needed to get it pass:

// src/components/TodoInput.vue
<template>
  <div>
    <input
      type="text"
      data-testid="todo-input"
      placeholder="Add todo item..."
      class="border border-gray-300 p-1 text-blue-700"
      v-model="newTodo">
    <button
        class="px-3 py-1 text-white bg-blue-500 mb-4"
        data-testid="todo-submit"
        @click.prevent="addTodo">Add
    </button>
  </div>
</template>

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

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

  methods: {
    addTodo () {
      // this.$store.commit('ADD_TODO', this.newTodo)
      this.newTodo = ''
    }
  }
}
</script>

I commented out the call to the store, because in the test, we haven't defined a store yet. Apart from that we want a test that forces us to uncomment this line.

Before copying and modifying the other tests, we will need to add a store as we did in the original test, but now we are going to create a dummy store with only one mutation: ADD_TODO. We implement this mutation using a jest mock function so we can spy on the calls to this function:

// tests/unit/components/TodoInput.spec.js
import TodoInput from '@/components/TodoInput'
import { createLocalVue, shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'

const localVue = createLocalVue()
localVue.use(Vuex)
let store

describe('The todo input component', function () {
  let wrapper

  const mutations = {
    ADD_TODO: jest.fn()
  }

  beforeEach(() => {
    store = new Vuex.Store({
      mutations
    })
    wrapper = shallowMount(TodoInput, {
      localVue,
      store
    })
  })

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

  it('can be mounted', () => {
    expect(wrapper.exists()).toBe(true)
  })
  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('')
  })
})

Now we have created the mock store, used it for the creation of the wrapper, and checked that the two tests still pass. The remaining tests can now be copied and rewritten to check if the jest spy has been called with right arguments.

// tests/unit/components/TodoInput.spec.js
  ...
  const mutations = {
    ADD_TODO: jest.fn()
  }
  ...

  it('allows for adding one todo item', async () => {
    await addTodo('My first todo item')
    // Note the first param is an empty object. That's the state the commit will be called with.
    // We didn't initialize any state, which causes the state to be an empty object.
    expect(mutations.ADD_TODO).toHaveBeenCalledWith({}, '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(mutations.ADD_TODO).toHaveBeenCalledTimes(2)
    // Note the first param is an empty object. That's the state the commit will be called with.
    // We didn't initialize any state, which causes the state to be an empty object.
    expect(mutations.ADD_TODO).toHaveBeenCalledWith({}, 'My first todo item')
    expect(mutations.ADD_TODO).toHaveBeenCalledWith({}, 'My second todo item')
  })

All we have to do to make these tests pass, is to uncomment the line in the component which calls the store:

// src/components/TodoInput.vue
  methods: {
    addTodo () {
      this.$store.commit('ADD_TODO', this.newTodo)
      this.newTodo = ''
    }
  }

One more test passes, but the last test fails with the following message:

Error: expect(jest.fn()).toHaveBeenCalledTimes(expected)

Expected number of calls: 2
Received number of calls: 4

The commit function was called 4 times instead of 2. The reason is that we didn't clear the mock function between tests, so the function accumulates all the calls. We can fix this by clearing all the mocks in the beforeEach function.

// tests/unit/components/TodoInput.spec.js
  ...
  beforeEach(() => {
    jest.clearAllMocks()
    store = new Vuex.Store({
      mutations
    })
    ...
  })
  ...

Now all tests pass. Let's cleanup the tests by removing the first test (can be mounted), because it is obsolete. We can also extract a function which checks if our commit spy has been called so the test is more readable. The complete test file now looks like:

// tests/unit/components/TodoInput.spec.js
import TodoInput from '@/components/TodoInput'
import { createLocalVue, shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'

const localVue = createLocalVue()
localVue.use(Vuex)
let store

describe('The todo input component', function () {
  let wrapper

  const mutations = {
    ADD_TODO: jest.fn()
  }

  beforeEach(() => {
    jest.clearAllMocks()
    store = new Vuex.Store({
      mutations
    })
    wrapper = shallowMount(TodoInput, {
      localVue,
      store
    })
  })

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

  function expectMutationToHaveBeenCalledWith (item) {
    // Note the first param is an empty object. That's the state the commit will be called with.
    // We didn't initialize any state, which causes the state to be an empty object.
    expect(mutations.ADD_TODO).toHaveBeenCalledWith({}, item)
  }

  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('')
  })
  it('allows for adding one todo item', async () => {
    await addTodo('My first todo item')
    expectMutationToHaveBeenCalledWith('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(mutations.ADD_TODO).toHaveBeenCalledTimes(2)
    expectMutationToHaveBeenCalledWith('My first todo item')
    expectMutationToHaveBeenCalledWith('My second todo item')
  })
})

This finishes the input component. The code can be found on github

Step 2: extract the todo list

We create the list of todo items component the same way as the form:

  • we force ourselves to create the component
  • we try to copy tests from the original component
  • we add the store to the tests

I will not discuss the forcing of the creation of the component anymore. You can find it in the test file as the first test (which I will leave in the code).

There are 2 tests from the original component covering the functionality of the list component:

    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')
    })
    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)
    })

We need to change these tests considerably to be useful in our new component because we should test that:

  • the displayed todo items are retrieved from the vuex store.
  • toggling of items is done using a store mutation.

We will mock both interactions by extending our mock store. Let's start with list of items:

// tests/unit/components/TodoList.spec.js
import { createLocalVue, shallowMount } from '@vue/test-utils'
import TodoList from '@/components/TodoList'
import Vuex from 'vuex'

const localVue = createLocalVue()
localVue.use(Vuex)
let store

describe('The TodoList component', function () {
  let wrapper

  const getters = {
    todos: jest.fn(() => [{
      description: 'First',
      done: false
    }, {
      description: 'Second',
      done: false
    }])
  }

  beforeEach(() => {
    store = new Vuex.Store({
      getters
    })
    wrapper = shallowMount(TodoList, {
      localVue,
      store
    })
  })

  it('can be mounted', () => {
    expect(wrapper.exists()).toBe(true)
  })
})

We mocked the store todos getter using the possibility of the jest mock function to return an implementation. Now we are ready to copy and modify the test which checks the order of the items:

// tests/unit/components/TodoList.spec.js
    ...
    function elementText (testId) {
      return wrapper.find(`[data-testid="${testId}"]`).text()
    }
    ...
    it('displays the items in the order they are present in the store', async () => {
      expect(elementText('todo-0')).toMatch('First')
      expect(elementText('todo-1')).toMatch('Second')
    })
    ...

And of course it fails. Lets copy just enough code from the original component to make this test pass:

// src/components/TodoList.vue
<template>
  <ul data-testid="todos" class="text-left">
    <li
        v-for="(todo, todoKey) of todos"
        :data-testid="`todo-${todoKey}`"
        :data-done="todo.done"
        :key="todoKey"
        class="block mb-3"
        :class="todo.done ? 'done' : ''"
    >
        <span
            :data-testid="`todo-${todoKey}-toggle`"
            @click.prevent="toggle(todo)"
            class="checkbox"
            :class="todo.done ? 'done' : ''"
        > {{ todo.done ? "Done" : "Mark done" }}</span>
      {{ todo.description }}
    </li>
  </ul>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'TodoList',

  computed: {
    ...mapGetters(['todos'])
  }
}
</script>

And it passes. To be sure we are really using the store, we add a check to ensure the getter is called.

// tests/unit/components/TodoList.spec.js
  beforeEach(() => {
    jest.clearAllMocks()
    ...
  })
  ...
  it('displays the items in the order they are present in the store', async () => {
    expect(getters.todos).toHaveBeenCalledTimes(1)
    expect(elementText('todo-0')).toMatch('First')
    expect(elementText('todo-1')).toMatch('Second')
  })
  ...

Note that in order for this test to pass we had to clear all the mocks as we did earlier, so we only count the calls of this specific test.

The only thing left to check is the toggle. When a todo is set to done, a mutation should have been committed to the store. First we prepare our mock store for this mutation:

// tests/unit/components/TodoList.spec.js
  ...
  const mutations = {
    TOGGLE_TODO: jest.fn()
  }

  beforeEach(() => {
    jest.clearAllMocks()
    store = new Vuex.Store({
      getters,
      mutations
    })
    ...
  })
  ...

And then we create the test:

// tests/unit/components/TodoList.spec.js
  it('items can be marked as done by clicking an element before the item.', async () => {
    await wrapper.find('[data-testid="todo-0-toggle"]').trigger('click')
    expect(mutations.TOGGLE_TODO).toHaveBeenCalledWith({}, {
      description: 'First',
      done: false
    })
  })

We make this test pass by copying the toggle method from Todo.vue:

// src/components/TodoList.vue
import { mapGetters } from 'vuex'

export default {
  name: 'TodoList',

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

  methods: {
    toggle (todo) {
      this.$store.commit('TOGGLE_TODO', todo)
    }
  }
}

This finishes the TodoList component. The code can be found on github.

Step 3: using the new components

Now we have the new components, we can rewrite the old component, so it uses these new components. We have the 'integration' test to check if it still works:

<template>
  <div>
    <h2 class="mb-4">{{ title }}</h2>
    <TodoInput />
    <TodoList />
  </div>
</template>

<script>
import TodoInput from '@/components/TodoInput'
import TodoList from '@/components/TodoList'

export default {
  name: 'Todo',

  components: {
    TodoInput,
    TodoList
  },

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

And it fails! What happened? Not to worry, this was expected. In the test we use shallowMount, but we extracted components, so we need to use mount which does render the subcomponents:

// tests/unit/components/Todo.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import Todo from '@/components/Todo'
import { createStore } from '@/store'

const localVue = createLocalVue()
localVue.use(Vuex)
let store

describe('The Todo.vue component', () => {
  beforeEach(() => {
    store = createStore()
  })
  it('Displays the title when passed as a prop', () => {
    const wrapper = mount(Todo, {
      localVue,
      store,
      propsData: {
        title: 'A random title'
      }
    })
    expect(wrapper.text()).toMatch('A random title')
    const wrapper2 = mount(Todo, {
      localVue,
      store,
      propsData: {
        title: 'Another random one'
      }
    })
    expect(wrapper2.text()).toMatch('Another random one')
  })
  describe('adding todo items', () => {
    let wrapper

    beforeEach(() => {
      wrapper = mount(Todo, {
        localVue,
        store,
        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')
    })
    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('')
    })
    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')
    })
    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)
    })
  })
})

Now it passes, and we are done! The code can be found on github.

Conclusion

This episode ends this series of Vue TDD by example. I created this series because I missed the refactoring part in the resources I found on TDD in Vue.

If you want to learn more there are more resources online. I learned a lot from:

Discussion (0)