Component Testing
Component tests sit between unit tests and end-to-end tests. They mount a Vue component in a simulated DOM, interact with it, and assert on the rendered output. The goal is to test components the way a user would use them — clicking buttons, filling inputs, reading text — without spinning up a browser or a backend.
Vue Test Utils is the official library for this. Paired with Vitest, it gives you fast, reliable component tests that run in milliseconds.
Mounting Components
The mount function renders a component and returns a wrapper with methods for querying and interacting with the DOM.
import { mount } from '@vue/test-utils'
import AlertBanner from './AlertBanner.vue'
it('renders the alert message', () => {
const wrapper = mount(AlertBanner, {
props: {
message: 'Something went wrong',
type: 'error',
},
})
expect(wrapper.text()).toContain('Something went wrong')
expect(wrapper.classes()).toContain('alert-error')
})
shallowMount renders the component but stubs all child components. Use it when you want to test a component in isolation without worrying about its children:
import { shallowMount } from '@vue/test-utils'
import Dashboard from './Dashboard.vue'
it('renders the dashboard layout', () => {
const wrapper = shallowMount(Dashboard)
// Child components render as stubs: <sidebar-stub />, <main-content-stub />
expect(wrapper.findComponent({ name: 'Sidebar' }).exists()).toBe(true)
})
In practice, prefer mount over shallowMount for most tests. Shallow rendering catches fewer real bugs because it replaces your actual child components with empty stubs. Kent C. Dodds wrote extensively about this — shallow rendering gives a false sense of security.
Testing Props
<!-- components/UserAvatar.vue -->
<script setup lang="ts">
const props = defineProps<{
name: string
imageUrl?: string
size?: 'sm' | 'md' | 'lg'
}>()
const initials = computed(() =>
props.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase(),
)
</script>
<template>
<div :class="['avatar', `avatar-${size || 'md'}`]">
<img v-if="imageUrl" :src="imageUrl" :alt="name" />
<span v-else>{{ initials }}</span>
</div>
</template>
import { mount } from '@vue/test-utils'
import UserAvatar from './UserAvatar.vue'
describe('UserAvatar', () => {
it('shows initials when no image is provided', () => {
const wrapper = mount(UserAvatar, {
props: { name: 'Jane Doe' },
})
expect(wrapper.text()).toBe('JD')
expect(wrapper.find('img').exists()).toBe(false)
})
it('shows the image when imageUrl is provided', () => {
const wrapper = mount(UserAvatar, {
props: { name: 'Jane Doe', imageUrl: '/avatar.jpg' },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/avatar.jpg')
expect(img.attributes('alt')).toBe('Jane Doe')
})
it('applies the size class', () => {
const wrapper = mount(UserAvatar, {
props: { name: 'Jane Doe', size: 'lg' },
})
expect(wrapper.find('.avatar').classes()).toContain('avatar-lg')
})
})
Testing Emits
<!-- components/ConfirmDialog.vue -->
<script setup lang="ts">
defineProps<{
title: string
message: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
<template>
<div class="dialog">
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<div class="actions">
<button class="btn-cancel" @click="emit('cancel')">Cancel</button>
<button class="btn-confirm" @click="emit('confirm')">Confirm</button>
</div>
</div>
</template>
import { mount } from '@vue/test-utils'
import ConfirmDialog from './ConfirmDialog.vue'
describe('ConfirmDialog', () => {
it('emits confirm when the confirm button is clicked', async () => {
const wrapper = mount(ConfirmDialog, {
props: { title: 'Delete?', message: 'This cannot be undone.' },
})
await wrapper.find('.btn-confirm').trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
})
it('emits cancel when the cancel button is clicked', async () => {
const wrapper = mount(ConfirmDialog, {
props: { title: 'Delete?', message: 'This cannot be undone.' },
})
await wrapper.find('.btn-cancel').trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('confirm')).toBeUndefined()
})
})
The emitted() method returns an object where keys are event names and values are arrays of emitted payloads. For events with payloads:
// If the component emits: emit('update', { id: 1, name: 'New Name' })
expect(wrapper.emitted('update')![0]).toEqual([{ id: 1, name: 'New Name' }])
Testing Slots
<!-- components/Card.vue -->
<script setup lang="ts">
defineProps<{
variant?: 'default' | 'outlined'
}>()
</script>
<template>
<div :class="['card', variant || 'default']">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
import { mount } from '@vue/test-utils'
import Card from './Card.vue'
describe('Card', () => {
it('renders the default slot content', () => {
const wrapper = mount(Card, {
slots: {
default: '<p>Card content here</p>',
},
})
expect(wrapper.find('.card-body').text()).toBe('Card content here')
})
it('renders named slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h3>Title</h3>',
default: '<p>Body</p>',
footer: '<button>Save</button>',
},
})
expect(wrapper.find('.card-header').text()).toBe('Title')
expect(wrapper.find('.card-footer').text()).toBe('Save')
})
it('hides header section when no header slot is provided', () => {
const wrapper = mount(Card, {
slots: { default: 'Content' },
})
expect(wrapper.find('.card-header').exists()).toBe(false)
})
})
Testing Async Behavior
Components that fetch data, wait for user input, or use nextTick require async handling. Vue Test Utils provides flushPromises for waiting on all pending promises.
<!-- components/UserProfile.vue -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const user = ref<{ name: string; email: string } | null>(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await fetch(`/api/users/${props.userId}`)
user.value = await response.json()
} finally {
loading.value = false
}
})
</script>
<template>
<div v-if="loading" class="skeleton">Loading...</div>
<div v-else-if="user" class="profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</template>
import { mount, flushPromises } from '@vue/test-utils'
import { vi } from 'vitest'
import UserProfile from './UserProfile.vue'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('UserProfile', () => {
it('shows loading state then user data', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ name: 'Alice', email: 'alice@example.com' }),
})
const wrapper = mount(UserProfile, {
props: { userId: '1' },
})
// Initially loading
expect(wrapper.find('.skeleton').exists()).toBe(true)
// Wait for fetch to complete
await flushPromises()
// Now shows the profile
expect(wrapper.find('.skeleton').exists()).toBe(false)
expect(wrapper.find('.profile h2').text()).toBe('Alice')
expect(wrapper.find('.profile p').text()).toBe('alice@example.com')
})
})
Providing Plugins and Global Config
Many components depend on plugins (Pinia, Vue Router, i18n). Provide them in the mounting options:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { createRouter, createMemoryHistory } from 'vue-router'
import ProductPage from './ProductPage.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/products/:id', component: ProductPage }],
})
it('renders product details', async () => {
router.push('/products/42')
await router.isReady()
const wrapper = mount(ProductPage, {
global: {
plugins: [
router,
createTestingPinia({
initialState: {
cart: { items: [] },
},
}),
],
},
})
// test assertions...
})
If you find yourself repeating this setup, create a helper:
// test/mount.ts
import { mount, MountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { Component } from 'vue'
export function mountWithPlugins(
component: Component,
options: MountingOptions<any> = {},
) {
return mount(component, {
...options,
global: {
plugins: [createTestingPinia(), ...(options.global?.plugins || [])],
stubs: { NuxtLink: true, ...(options.global?.stubs || {}) },
...options.global,
},
})
}
Snapshot Testing
Snapshot tests capture the rendered HTML and compare it against a stored snapshot. They catch unintended visual changes.
it('matches the snapshot', () => {
const wrapper = mount(AlertBanner, {
props: { message: 'Heads up!', type: 'info' },
})
expect(wrapper.html()).toMatchSnapshot()
})
On the first run, Vitest creates a .snap file with the rendered HTML. On subsequent runs, it compares the output against the stored snapshot. If it differs, the test fails.
Snapshots are useful for catching regressions but painful to maintain if overused. A team that snapshots every component ends up blindly updating snapshots whenever anything changes. Use them sparingly — for components with complex rendering logic where manually asserting every element would be tedious.
When to Test Components vs E2E
Component tests are fast and focused. They test a single component's behavior: does it render correctly with these props? Does it emit the right events? Does it handle edge cases?
Use component tests for: form validation logic, conditional rendering, computed display values, event handling, slot rendering.
Use E2E tests for: multi-page flows (signup, checkout), navigation, authentication, interactions that span multiple components and API calls.
A practical ratio: for every 10 component tests, you might write 1-2 E2E tests. Component tests cover the breadth of your UI logic; E2E tests cover the critical user journeys.
Common Pitfalls
Testing CSS classes instead of behavior. expect(wrapper.classes()).toContain('btn-primary') tests your CSS naming, not your component's behavior. Test what the user sees: expect(wrapper.text()).toContain('Submit').
Not awaiting trigger(). trigger('click') returns a promise. If you forget await, your assertions run before the click handler finishes, and tests pass when they should fail.
Over-relying on find with CSS selectors. Selectors like .card > div:nth-child(2) span.price are fragile. Use data-testid attributes or findComponent for more resilient selectors.
Testing third-party components. Do not test that Vuetify's v-btn renders correctly. Test that your component passes the right props to it and handles its events. Stub third-party components when their rendering is not what you are testing.
Snapshot overuse. If your test file is just expect(wrapper.html()).toMatchSnapshot() for 20 components, you have a maintenance problem. Snapshots should complement specific assertions, not replace them.
Key Takeaways
- Use
mountovershallowMountfor most tests — you want to test real component integration, not stubs. - Test props, emits, and slots as the public API of your components.
- Use
flushPromisesto handle async behavior in tests. - Create a
mountWithPluginshelper to reduce boilerplate when many components need Pinia, Router, or other plugins. - Component tests cover UI logic; E2E tests cover user journeys. They are complementary, not redundant.