Skip to content

Frontend testing

Manuel Meister edited this page Jan 2, 2023 · 1 revision

Writing tests for the frontend can seem like a daunting task at first. Fortunately, we have the great Vue Testing Library which helps us in writing component tests which are based on real user behaviour. This improves the realism of the test, helps us not get lost in the implementation details and lets us think in terms of real actions on the page.

Vue testing library is the vue specific version of the broader "testing library" project. A good place to start reading about it are the guiding principles.

Writing tests for a Vue component

Let's say we have the a component inside a directory in our project, and we want to create a test for it:

src/
  components/
    InputWithConfirmation.vue
    __tests__/
      InputWithConfirmation.spec.js

The InputWithConfirmation.vue component file contains something like this:

<template>
  <div>
    <ApiTextField uri="/something/1a2b3c4d" fieldname="nickname" :label="$tc('components.inputWithConfirmation.nickname')" />
    <v-btn @click="updateValue">{{ $tc('components.inputWithConfirmation.confirm') }}</v-btn>
    <span>{{ value }} (last confirmed on {{ lastChangeAt.format('L') }})</span>
  </div>
</template>
<script>
export default {
  name: 'InputWithConfirmation',
  props: {
    lastChanged: { type: Object, default: () => this.$date() }
  }
  data() {
    return {
      value: null,
      localLastChange: this.lastChanged,
    }
  },
  methods: {
    updateValue() {
      const newValue = this.api.get('/something/1a2b3c4d').nickname
      this.value = newValue
      this.localLastChange = this.$date()
    }
  }
}
</script>

This component may seem complex to test because it...

  • Includes Vuetify components (inside ApiTextField)
  • Uses translations (for the label)
  • Uses day.js (for calculating and displaying the last change date)
  • Displays some dynamic content (the value) after clicking a button
  • Emits some events when clicking the button, which is something the user doesn't always immediately see
  • Performs API calls when editing the field value

If you use the render function from our renderWithVuetify.js helper, the first three points are already solved for you. Let's look at how to solve the other points.

Testing basic interactions and display

If you don't know where to start writing a test, the given - when - then structure often helps with structuring your thoughts and finding a way to test a feature. In the test, it looks like this:

describe('InputWithConfirmation', () => {
  it('displays the input value after clicking the button', () => {
    // given
    
    // when
    
    // then
    
  })
})

In the given section, we fill in any preparation for the test. First, we have to render our component. Let's simulate as if our component was rendered like <InputWithConfirmation :last-changed="$date(new Date(2018, 8, 18))" />)

+import { render } from '@/test/renderWithVuetify.js'
+import InputWithConfirmation from '@/components/InputWithConfirmation.vue'
+import dayjs from '@/common/helpers/dayjs.js'
 describe('InputWithConfirmation', () => {
   it('displays the input value after clicking the button', () => {
     // given
+    render(InputWithConfirmation, {
+      props: {
+        lastChanged: dayjs(new Date(2018, 8, 18)),
+      },
+    })
 
     // when
     
     // then
     
   })
 })

See the Vue Testing Library docs for more info on all the available options for the render call. Note that we imported the render function from our renderWithVuetify.js code, but the options are exactly the same.

Next, still in the given section, we need to find our button:

 import { render } from '@/test/renderWithVuetify.js'
 import InputWithConfirmation from '@/components/InputWithConfirmation.vue'
 import dayjs from '@/common/helpers/dayjs.js'
+import { screen } from '@testing-library/vue'
 describe('InputWithConfirmation', () => {
+  it('displays the input value after clicking the button', async () => {
-  it('displays the input value after clicking the button', () => {
     // given
     render(InputWithConfirmation, {
       props: {
         lastChanged: dayjs(new Date(2018, 8, 18)),
       },
     })
+    const button = await screen.findByText('Bestätigen')
 
     // when
     
     // then
     
   })
 })

To learn about all the possible ways to find elements on the page, see the corresponding page in the Vue Testing Library docs. Vue testing library intentionally does not provide an easy option for e.g. querying by CSS selectors, because we should write tests which rely on what the user sees instead of what is technically going on in the HTML code.

Vue testing library also helps us find accessibility problems to some extent: For example imagine our button wouldn't have text written on it, but only an icon. The easiest option Vue testing library offers us is to add an aria-label="Confirm" on the button, and use the screen.findByLabelText('Confirm') call to locate that button. The reason is that people using assistive technology such as screen readers maybe couldn't see the icon on an icon-only button either, so it is important to add an aria-label so that the screen reader can read the purpose of the button to the user.

Note that in the above test, we write the German translation of the button directly into the test. This is recommended by the author of Testing Library, because this way, if someone changes the translation, we are forced to have a look at the test and explicitly confirm that the new translation is suitable for our feature.

In the when section, we fill in a single action which is also usually described in the test name. Sometimes the action requires multiple lines, but often it's just one line of code.

 import { render } from '@/test/renderWithVuetify.js'
 import InputWithConfirmation from '@/components/InputWithConfirmation.vue'
 import dayjs from '@/common/helpers/dayjs.js'
 import { screen } from '@testing-library/vue'
+import user from '@testing-library/user-event'
 describe('InputWithConfirmation', () => {
   it('displays the input value after clicking the button', async () => {
     // given
     render(InputWithConfirmation, {
       props: {
         lastChanged: dayjs(new Date(2018, 8, 18)),
       },
     })
     const button = await screen.findByText('Bestätigen')
 
     // when
+    await user.click(button)

     // then
     
   })
 })

Finally, we can assert on the state of the UI in the then section:

 import { render } from '@/test/renderWithVuetify.js'
 import InputWithConfirmation from '@/components/InputWithConfirmation.vue'
 import dayjs from '@/common/helpers/dayjs.js'
 import { screen } from '@testing-library/vue'
 import user from '@testing-library/user-event'
 describe('InputWithConfirmation', () => {
   it('displays the input value after clicking the button', async () => {
     // given
     render(InputWithConfirmation, {
       props: {
         lastChanged: dayjs(new Date(2018, 8, 18)),
       },
     })
     const button = await screen.findByText('Bestätigen')
 
     // when
     await user.click(button)

     // then
+    expect(await screen.findByText('01.01.2022')).toBeInTheDocument()
+    expect(screen.queryByText('18.08.2022')).not.toBeInTheDocument()
   })
 })

Again, to find the correct way to query the elements on the page, see the corresponding page in the Vue Testing Library docs.

Now we have the problem that clicking the button will always store the current date and display it. But in the test we want to be as specific as possible about the expected behaviour, and avoid tests which change their behaviour depending on the day (or time of day) on which they are run. In other words, we want our tests to be deterministic, i.e. to behave the same no matter where and when and under which circumstances they are run. To solve this, we can mock the this.$date() function in the tested component. This results in the final version of our test:

 import { render } from '@/test/renderWithVuetify.js'
 import InputWithConfirmation from '@/components/InputWithConfirmation.vue'
 import dayjs from '@/common/helpers/dayjs.js'
 import { screen } from '@testing-library/vue'
 import user from '@testing-library/user-event'
 describe('InputWithConfirmation', () => {
   it('displays the input value after clicking the button', async () => {
     // given
     render(InputWithConfirmation, {
       props: {
         lastChanged: dayjs(new Date(2018, 8, 18)),
       },
+      mocks: {
+        $date: () => dayjs(new Date(2022, 1, 1)),
+      },
     })
     const button = await screen.findByText('Bestätigen')
 
     // when
     await user.click(button)

     // then
     expect(await screen.findByText('01.01.2022')).toBeInTheDocument()
     expect(screen.queryByText('18.08.2022')).not.toBeInTheDocument()
   })
 })

You may also repeat the when and then sections, as long as you don't overdo it:

it('test', () => {
  // given
  prepare()
  // when
  action1()
  // then
  expectations1()
  // when
  action2()
  // then
  expectations2()
})

If a test gets too long, it might be better to split it up into two separate tests which can in the future fail independently and therefore give us more information on what exactly went wrong:

it('test1', () => {
  // given
  prepare()
  // when
  action1()
  // then
  expectations1()
})

it('test2', () => {
  // given
  prepare()
  action1()
  // when
  action2()
  // then
  expectations2()
})

TODO mention the debugging options

Testing emitted events of a component

TODO

Testing API calls performed by a component

TODO

Writing tests for a plain javascript file

When extracting helpers and "business logic" into separate plain javascript files, we write extensive unit tests for these files. The reason is that extracting code into a separate file is often intended to make the code re-usable in multiple places, and so we can benefit a lot from writing good tests for this logic.

For testing plain javascript files, Vue Testing Library is not used. Instead, we write plain Jest unit tests. These are also placed in a __tests__ directory next to the actual business logic:

myLogic.js
__tests__/
  myLogic.spec.js

Imagine we want to write a unit test for this javascript function (we'll ignore the fact that lodash already provides a much more advanced implementation of such a function for us):

export function kebabToCamelCase(input) {
  function capitalize(word) {
    const lowerCase = word.toLowerCase()
    return lowerCase[0] + word.slice(1)
  }
  return input.split('-').map(word => capitalize(word)).join('')
}

Just as for components, with plain javascript files it can also help to follow a given - when - then structure to get started with writing the test.

describe('kebabToCamelCase', () => {
  it('converts kebab to camel case', () => {
    // given
    
    // when
    
    // then
    
  })
})

In the given section, we fill in any preparation for the test:

 describe('kebabToCamelCase', () => {
   it('converts kebab to camel case', () => {
     // given
+    const input = 'abc-123-test'
 
     // when
     
     // then
     
   })
 })

In the when section, we perform a (single) action:

+ import { kebabToCamelCase } from '../stringUtils.js'
+
 describe('kebabToCamelCase', () => {
   it('converts kebab to camel case', () => {
     // given
     const input = 'abc-123-test'
 
     // when
+    const result = kebabToCamelCase(input)
 
     // then
     
   })
 })

Finally, in the then section, we make assertions using the expect function from jest:

 import { kebabToCamelCase } from '../stringUtils.js'
 
 describe('kebabToCamelCase', () => {
   it('converts kebab to camel case', () => {
     // given
     const input = 'abc-123-test'
 
     // when
     const result = kebabToCamelCase(input)
 
     // then
+    expect(result.length).toBeLessThanOrEqual(input.length)
+    expect(result).toBe('abc123Test')
   })
 })

Writing parameterized tests

For functions which simply take some input and return some output, it can be a lot of setup to copy and paste the same 10+ lines from above many times to make sure many input-output combinations are tested. In such cases, we can write parameterized tests, which allow us to write the 10 lines only once, and pass in an array of input-output combinations, which are each executed as a separate test. Here is what such a parameterized test could look like for our kebabToCamelCase function:

import { kebabToCamelCase } from '../stringUtils.js'

describe('kebabToCamelCase', () => {
  it.each([
    [[undefined], undefined],
    [[null], null],
    [[false], false],
    [[''], ''],
    [['abc-123-test'], 'abc123Test'],
    [['test-string'], 'testString'],
    [['alreadyCamelCase'], 'alreadyCamelCase'],
    [['can-it-handle_special.characters$fine?'], 'canItHandle_sepcial.characters$fine?'],
    [['how-about-emoji-😉-characters'], 'howAboutEmoji😉Characters'],
  ])('maps %p to %p', (input, expected) => {
    // given

    // when
    const result = kebabToCamelCase(...input)

    // then
    expect(result).toBe(expected)
  })

  // Of course, we can still add separate, single run it(...) statements
  // for very special cases which don't fit into our parameterized test:
  it('throws an exception when passed a number', () => {
    // given
    const input = 1

    // when
    expect(() => {
      kebabToCamelCase(input)
    })
    
    // then
    .toThrow(/yuck/)
  })
})