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)

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 notes connected by topic, not algorithmic noise.