DEV Community

Nasr Galal
Nasr Galal

Posted on

Component Testing in Vue

Testing Concept πŸ“‡

Testing is a methodology applied for checking if the code written is actually giving the desired output.

It is a must to test your components for the following reasons:

  1. Minimize regressions
  2. Ensure code integrity, scalability, and quality
  3. Monitor performance
  4. Get a safe development setup

Unit testing πŸ“

Unit testing is basically focusing on the outputs in a component scale, since Vue is actually based on the component design system.

Before we go deeper, we need to know and understand what to test actually and how to structure our tests accordingly.


What to test ❓

Many of my colleagues are actually testing component inputs ❌❌. This actually is not what the testing concept is here for, therefore, We need actually to test component output instead. We'll be using @vue/test-utils with jest testing framework.


Testing component output

To organize this a bit, here are the things we need actually to
test in a Vue component:

  1. Rendered template
  2. Emitted events
  3. Side effects (VueX actions, vue-router, calling imported functions, methods, mixins, .... etc)

Now I will show the traditional way ❌ (incorrect) ❌ that most developers do to structure their tests:

describe('methods', () => {
  /* Testing every method in isolation */
})

describe('computed', () => {
  /* Testing every computed property in isolation */
})

describe('template', () => {
  /* Testing what is rendered. With the snapshot */
})
Enter fullscreen mode Exit fullscreen mode

As shown above , the tests look structured. However, it is following the context of testing the ❌ inputs ❌ instead of the βœ”οΈ outputs βœ”οΈ!

let's have a look at this simple template:

<template>
  <main>
    <div v-if="loading">
      Loading ...
    </div>
    <template v-else>
      <p v-if="error">
        Something went wrong!
      </p>
      <div v-else>
        <!-- some data -->
      </div>
    </template>
  </main>
</template>
Enter fullscreen mode Exit fullscreen mode

As seen above , it is a simple component that's setup for synchronous fetching of some data from the API. To test this out, let's think about it as a state machine.

So the component either gets data, or loads an error, right?
Now let's look at this testing structure:

describe('when loading', () => {
  it.todo(`renders 'Loading...' text`)

  it.todo(`does not render the error message`)

  it.todo(`does not render data`)
})

describe('when there is an error', () => {
  it.todo(`does not render 'Loading...' text`)

  it.todo(`renders error message`)

  it.todo(`does not render data`)
})

Enter fullscreen mode Exit fullscreen mode

So, in the example above, we have divided the test specs into 2 main groups as we have 2 main phases we should test:

  1. Within loading
  2. When there is an error

This will organize our specs a bit, as our component might not render the error message while loading if something happened for some reason, or it might be actually in loading state, but it is not rendering the loading text.

That way, our testing spec will be more logical and this makes it easier to interpret and debug without any headache.


Start with component factory

Component factory is simply a method that creates (shallow mounts) Vue component

import { shallowMount } from '@vue/test-utils';

describe('My component test', () => {
  let wrapper;

  // Component Factory
  function createComponent() {
    wrapper = shallowMount(MyComponent, {/* optional params */})
  }

  // Destroy wrapper
  afterEach(() => {
    wrapper.destroy()
  })
})
Enter fullscreen mode Exit fullscreen mode

The previous snippet shows that we create a changing wrapper variable and we do optionally set a createComponent() function, but why is that?

The thing is, in some test cases, you might try to mount the component with different props, or may be add some mocks. So we will need to change the wrapper and remount the component.


Use helpers to help you find elements and components

For very complex components, we may use helpers to help us find elements and components easily.
Let's take a look at this snippet:

import { shallowMount } from '@vue/test-utils';


describe('My component test', () => {
  let wrapper;

  const findConfirmBtn = wrapper.find('[data-testid="confirm-btn"]')
  const findModalComp = wrapper.findComponent(MyModalComponent)

  // Component Factory
  function createComponent() {
    wrapper = shallowMount(MyComponent, {/* optional params */})
  }


  // Destroy wrapper
  afterEach(() => {
    wrapper.destroy()
  })

  it('renders a modal', () => {
    createComponent();
    expect(findModalComp.exists()).toBe(true)
  })
})
Enter fullscreen mode Exit fullscreen mode

So, as we saw there, we have created like a boiler plate for locating different elements and we did make use of the createComponent() function which is really great!

Note => using data selectors like [data-testid="something"] is a best practice

the [data-testid="something"] is important because we do apply refactors from time to time and we might change either the component name or the classes attached to that component. This will guarantee that the test specs won't be affected and we are good to go.

❌ Never ❌ ever ❌ test component internals

It is a really bad practice to test the component internals. Let me show you an example:

export default {
  data() {
    return {
      count: 0
    }
  }
  computed: {
    double() {
      return this.count * 2
    }
  }
  methods: {
    incrementCount() {
      this.count++
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The normal way that comes in mind to test this out will be something like this:

it('Calculates double correctly', () => {
  createComponent({ data: { count: 1 } })
  expect(wrapper.vm.double).toBe(2)
})


it('Calls correct method on btn click', () => {
  createComponent()
  jest.spyOn(wrapper.vm, 'incrementCount').mockImplementation(() => {})

  findIncrementBtn().trigger('click')
  expect(wrapper.vm.incrementCount).toHaveBeenCalled()
  expect(wrapper.vm.count).toBe(1)
})
Enter fullscreen mode Exit fullscreen mode

This is actually a wrong approach ❌❌❌ as it tests if the method is called when clicking the btn. That way, we are retesting Vue framework and thus, this is way far from testing our logic.

if you are saying: "hey! We should test our computed props in isolation 😡", then you are trying to reverse the engineering made in the creation of the SFC component. It is not practical to isolate a computed prop from the SFC as this will affect rendering the template!

In this case we can say that, the best way to check the computed props is with rendering the template βœ”οΈ. I will show you how in a moment.

So, let's imagine that our template is looking like this:

<template>
  <div>
    <span data-testid="count">Count is: {{ count }}</div>
      <button data-testid="increment-button" @click="incrementCount">
        Inctrement
      </button>
      <p data-testid="double">Count x2: {{ double }}</p>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

So, instead of testing the internal options API props. We can test the rendered results/outputs in the template itself βœ”οΈ like so:

const findDouble = wrapper.find('[data-testid="double"]')

it('Calculates double correctly', () => {
  createComponent({ data: { count: 1 } })
  // expect(wrapper.vm.double).toBe(2) //This was the wrong approach
  expect(findDouble().text()).toBe(`Count x2: 2`) // This is the best practice
})

// for an extended version, jest supports this format
it.each`
  a     |  expected
  ${0}  |  ${0}
  ${1}  |  ${2}
  ${10}  |  ${20}
  ${100}  |  ${200}
`('renders double count as $expected when count is $a',
  ({ a, expected } => {
    createComponent({ data: { count: a } })

    expect(findDouble().text()).toBe(`Count x2: ${expected}`)
  })
 )
Enter fullscreen mode Exit fullscreen mode

This way, we are neither checking the template nor checking the internal props because we don't have to. Instead, we are checking the outputs in the template βœ”οΈ βœ”οΈ βœ”οΈ.

That means, we don't care about how the logic was built for doubling a count as long as the output is always correct. that's why we do test edge cases to make sure that there are no regressions what so ever.

With the same approach we can test the rest of data and methods the same way like this:

const findCount = () => wrapper.find('[data-testid="count"]')
const findIncrementBtn = () => wrapper.find('[data-testid="increment-btn"]')

it('Calls correct method on btn click', async () => {
  createComponent()
  expect(findCount().text()).toBe('Count: 0')

  findIncrementBtn().trigger('click')
  await nextTick()
  expect(findCount().text()).toBe('Count: 1')
})
Enter fullscreen mode Exit fullscreen mode

This way βœ”οΈ we are testing the rendered output on the template.


πŸ‘ Rules of thumb πŸ‘

  1. Forget about asserting wrapper.vm
  2. Never spy on methods
  3. If we rename method or computed, test should pass because we do care about the output only

Why we shouldn't test the component internals ❓

The trick here is that when you test a method in isolation, it passes, but if a developer references it wrongly in the template, the test will still pass and that is not what we are targeting, as the custom component will be still wrong and we are testing Vue itself 😏

We should test the rendered output to manage the typos, bugs or wrong references. So, the test should not pass if we reference the wrong attributes or methods in the template.


Always follow the user

Back to our example

it('Calculates double correctly', () => {
  createComponent({ data: { count: 1 } })
  expect(findDouble().text()).toBe(`Count x2: 2`)

  //  now if the user increases the count
  wrapper.setData({ count: 2})
  expect(findDouble().text()).toBe(`Count x2: 4`)
})
Enter fullscreen mode Exit fullscreen mode

This test looks okay, but still wrong ❌❌❌.. as we should be testing the user interaction itself

it('Calculates double correctly', async() => {
  createComponent({ data: { count: 1 } })
  expect(findDouble().text()).toBe(`Count x2: 2`)

  //  now if the user increases the count
  findIncrementBtn().trigger('click')
  await nextTick()
  expect(findDouble().text()).toBe(`Count x2: 4`)
})
Enter fullscreen mode Exit fullscreen mode

This way, we are checking when user clicks a button βœ”οΈ, it should reflect the value change in the template, and that way, our test is touching the business logic that we need actually to verify βœ”οΈ.


Final thoughts

Child components are black boxes

We should be using shallowMount instead of mount as we need to focus on the component we are testing.

Don't forget about Vue microtasks

Be sure not to use microtasks like nextTick , otherwise, the test expectation will fail.


Happy coding! πŸ’»


Top comments (0)