Skip to content

Vue 3 testing cheat sheet

What are the basic constructs used for testing in Vue?

7 min read

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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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

TypeScript
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:

HTML
<input @input="onInput" />

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

You need to write it like this:

HTML
<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:

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:

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

TypeScript
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)

Shell command
pnpm add -D @pinia/testing

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

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:

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:

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:

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

TypeScript
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

TypeScript
// 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

TypeScript
// Data stores

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

Teleport target in tests

In test/setup.ts:

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:

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:

TypeScript
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.

TypeScript
await wrapper.vm.$nextTick()

// Or...

await textInput.trigger('focus')

References

More posts connected by shared tags.