Vue 3 testing cheat sheet

What are the basic constructs used for testing in Vue?

2022-07-29
2023-01-29
frontend
vue 3
testing

Lately, I’ve been realizing that co-located tests are better.

It’s easier to see which components are tested or not, or quickly open a component’s tests right there! — as opposed to going to a different folder to crawl a hierarchy of test files.

mount vs shallowMount

mount will render the whole component and all of its child components.
shallowMount will stub all the child component.

I think it’s preferably to use mount and only stub the things that you need.

Stubs

This is how you can stub sub components:

import { mount } from '@vue/test-utils'
import App from './App.vue'

test('mount component', () => {
  expect(App).toBeTruthy()

  const wrapper = mount(App, {
    global: {
      stubs: {
        MiniMap: true
      }
    }
  })

  expect(wrapper.text()).toContain('MiniMap')
  expect(wrapper.html()).toContain('<mini-map-stub')

  expect(wrapper.html()).toMatchSnapshot()
})

If you pass true to the stub definition it’ll render a component like:
<component-name-stub attr-1="algo" other-attr="otro"></component-name-stub>
so you can test against <component-name-stub or </component-name-stub>.

Why not test against <component-name-stub>?
Because if you are passing props or assigning attributes — like classes, etc. — then you won’t have a match.

Another way is to provide your own stub template and test against that:

import { mount } from '@vue/test-utils'
import App from './App.vue'

test('mount component 2', () => {
  expect(App).toBeTruthy()

  const wrapper = mount(App, {
    global: {
      stubs: {
        MiniMap: {
          template: '<div>|THE MINI MAP|</div>'
        }
      }
    }
  })

  expect(wrapper.text()).toContain('MiniMap')
  expect(wrapper.text()).toContain('|THE MINI MAP|')

  expect(wrapper.html()).toMatchSnapshot()
})

Why not test against <div>|THE MINI MAP|</div>?
For the same reasons described above for <component-name-stub>.

Props and inject

This is how you pass props to a component being tested and also how you can provide a value for injection:

import { mount } from '@vue/test-utils'
import MiniMapControls from './MiniMapControls.vue'

test('toggles control pane', () => {
  const wrapper = mount(MiniMapControls, {
    props: {
      addedTexts: [],
      frameHeight: 400,
      frameWidth: 600,
      layers: []
    },
    global: {
      provide: {
        ppi: 96
      }
    }
  })

  // ...
})

Test click actions and their effects

We’ll need async tests for this:

import { mount } from '@vue/test-utils'
import MiniMapControls from './MiniMapControls.vue'

test('toggles control pane', async () => {
  const wrapper = mount(MiniMapControls, {
    props: {
      addedTexts: [],
      frameHeight: 400,
      frameWidth: 600,
      layers: []
    },
    global: {
      provide: {
        ppi: 96
      }
    }
  })

  expect(wrapper.find('.control-pane').exists()).toBe(false)

  const addIconButton = wrapper.get('button[name="Add icon"]')

  await addIconButton.trigger('click')
  expect(wrapper.find('.control-pane').exists()).toBe(true)

  await addIconButton.trigger('click')
  expect(wrapper.find('.control-pane').exists()).toBe(false)
})

Test with a real router

import { flushPromises, mount } from '@vue/test-utils'

import App from './App.vue'
import { router } from './router'

test('mount component', () => {
  expect(App).toBeTruthy()

  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })

  expect(wrapper.text()).toContain('rev-')
})

test('render landing page', async () => {
  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })

  await router.isReady()

  expect(wrapper.text()).toContain('some text that is mounted on the / path component')
})

test('render 404 page', async () => {
  const wrapper = mount(App, {
    global: {
      plugins: [router]
    }
  })

  router.push('/non-existing')
  await flushPromises()

  expect(wrapper.text()).toContain('404')
})

Test event handling + function calling

We want to test that functions are called —usually in response to event handling.

To have spies being called you'll need to add parentheses to the event handlers.

Instead of having the code like this:

<input @input="onInput" />

<HelloWorld msg="Vite + Vue" @something="someEventHandler" />

You need to write it like this:

<input @input="onInput($event)" />

<HelloWorld msg="Vite + Vue" @something="someEventHandler()" />

Else, when testing with spies it WON’T work.

See previous discussion about this bug:

DOM element example

TextInput.test.ts:

import { mount } from '@vue/test-utils'

import TextInput from './TextInput.vue'

describe('rendering', () => {
  test('component mounts', () => {
    expect(TextInput).toBeTruthy()

    const wrapper = mount(TextInput, {
      props: {
        label: 'Name',
        placeholder: 'Input your name'
      }
    })

    expect(wrapper.text()).toContain('Hello')
    expect(wrapper.html()).toContain('Input your name')
  })

  test('function is called on input events', () => {
    const wrapper = mount(TextInput, {
      props: {
        label: '',
      }
    })

    const inputSpy = vi.spyOn(wrapper.vm, 'setIsFocused')

    const textInput = wrapper.get('input')
    textInput.trigger('focus')
    textInput.trigger('blur')

    expect(inputSpy).toHaveBeenCalledTimes(2)
  })
})

Test CSS classes

Sometimes you need to test for CSS classes because it’s a presentational component that accepts variants, for example:

Divider.test.ts:

import { mount } from '@vue/test-utils'

import Divider from './Divider.vue'

describe('rendering', () => {
  test('exists', () => {
    expect(Divider).toBeTruthy()
  })

  test('main variant', () => {
    const wrapper = mount(Divider, {
      props: {
        variant: 'main'
      }
    })

    expect(wrapper.classes()).toContain('main-variant')
    expect(wrapper.classes()).toContain('bg-border-red-400')
  })
})

Inputs

import { mount } from '@vue/test-utils'

import TextInput from './TextInput.vue'

describe('rendering', () => {
  test('the value is set', () => {
    const wrapper = mount(TextInput, {
      props: {
        modelValue: 'Example value'
      }
    })

    const input = wrapper.find('input')

    expect(input.element.value).toBe('Example value')
  })
})

Components that use stores (Pinia)

pnpm add -D @pinia/testing

If we are using a store in src/App.vue, then in src/App.test.ts:

import { createTestingPinia } from '@pinia/testing'

import { router } from '@/router'

test('rendering', () => {
  expect(App).toBeTruthy()

  const wrapper = mount(App, {
    global: {
      plugins: [router, createTestingPinia()]
    }
  })
})

Set test coverage thresholds

If you followed the guide for setting up a new Vue + Vite app. Then you already added the c8 dependency for code coverage.

In vite.config.ts:

export default defineConfig(({ mode }) => {
  return {
    test: {
      coverage: {
        skipFull: true,
        branches: 60,
        functions: 60,
        lines: 60,
        statements: 60,
      }
    }
  }
})

Add a test setup file

In vite.config.ts:

export default defineConfig(({ mode }) => {
  return {
    test: {
      setupFiles: ['./test/setup.ts']
    }
  }
})

Mocks

window.matchMedia

Here we’ll need to mock the matchMedia window property.

In test/setup.ts:

import { vi } from 'vitest'


// Global objects and properties

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query) => {
    return {
      matches: false,
      media: query,
      onchange: null,
      addListener: vi.fn(), // deprecated
      removeListener: vi.fn(), // deprecated
      addEventListener: vi.fn(),
      removeEventListener: vi.fn(),
      dispatchEvent: vi.fn()
    }
  }),
})

axios

vi.mock('axios', () => {
  return {
    default: {
      create() {
        return {
          post: vi.fn(),
          get() {
            return Promise.resolve({})
          },
          interceptors: {
            request: {
              use: vi.fn()
            },
            response: {
              use: vi.fn()
            }
          }
        }
      }
    }
  }
})

Data services

// Data services

vi.mock('@/services/data/baseService', () => {
  return {
    createBaseService: () => ({}),
    paginationParams: vi.fn(),

    baseService: {
      get: () => Promise.resolve({ data: [] })
    }
  }
})

vi.mock('@/services/data/userService', () => {
  return {
    userService: {
      list: () => Promise.resolve({ data: [] }),
      me: () => Promise.resolve({ data: { email: 'my@email' } })
    }
  }
})

Data stores

// Data stores

vi.mock('@/stores/authStore', () => {
  return {
    useAuthStore() {
      return {
        maybeLoggedIn: () => Promise.resolve(true)
      }
    }
  }
})

Teleport target in tests

In test/setup.ts:

// Setup Teleport target for tests

beforeAll(() => {
  const el = document.createElement('div')
  el.id = 'action-bar'
  document.body.append(el)
})

Components that use Axios

Here we’ll need to mock the Axios instance.

In test/setup.ts:

import { vi } from 'vitest'

vi.mock('axios', () => {
  return {
    // This is because it uses a default export
    default: {
      create() {
        return {
          get() {
            return Promise.resolve({})
          },
          post: vi.fn(),
          interceptors: {
            request: {
              use: vi.fn(),
            },
            response: {
              use: vi.fn(),
            },
          },
        }
      },
    },
  }
})

Mock imports

Let’s say you have a data service (for users) in a component you are testing; you could mock it like this:

vi.mock('@/services/data/userService', () => {
  return {
    // This is because it uses a named export
    userService: {
      me: () => Promise.resolve({ id: 1, email: 'user@email.com' }),
    },
  }
})

If this service needs to be mocked in several tests, you can put it in your test/setup.ts file.

Misc notes

If you aren’t seeing expected changes in state or the DOM when testing, remember to always use await when calling $nextTick or trigger.

await wrapper.vm.$nextTick()

// Or...

await textInput.trigger('focus')

References