Unit testing Vue components has become non-negotiable in modern frontend development, yet many teams still ship components they haven't properly validated. With Vitest gaining momentum for its blazing-fast execution and seamless integration with Vue's ecosystem, there's no reason to skip this step. Testing Vue components with Vitest: a step-by-step guide walks you through the tooling and patterns that let you write fast, meaningful tests before your code reaches production.
Why Vitest for Vue component testing
Vitest is a unit test framework built on Vite, which means it compiles your tests and modules in milliseconds, not seconds. When you're running hundreds of component tests across a medium-to-large Vue application, that speed difference adds up fast. Unlike Jest, which has its own module resolution and transformation pipeline, Vitest reuses your existing Vite configuration, reducing friction and keeping your testing setup close to your development setup.
Vue applications benefit from this speed because components are naturally isolated units. A form input, a modal, a data table—these are perfect candidates for unit testing. By pairing Vitest with the right assertion libraries and mounting utilities, you can verify component behavior without running a browser or loading your entire app bundle.
Mounting components with Vue Test Utils
Vue Test Utils is the official testing library for Vue components. It provides a mount() function that creates a component instance in an isolated environment, letting you inspect its output, trigger events, and verify state changes.
Here's a basic example. Imagine a component that accepts a button label and emits a click event:
// Button.vue
<template>
<button @click="$emit('clicked')">{{ label }}</button>
</template>
<script setup lang="ts">
defineProps<{ label: string }>()
</script>Your test would look like this:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button.vue', () => {
it('renders the label prop', () => {
const wrapper = mount(Button, {
props: { label: 'Click me' }
})
expect(wrapper.text()).toBe('Click me')
})
it('emits clicked event when button is clicked', async () => {
const wrapper = mount(Button, {
props: { label: 'Submit' }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('clicked')).toHaveLength(1)
})
})The mount() call creates a wrapper around your component. You can inspect the DOM with find(), trigger events with trigger(), and check emitted events with emitted(). The async/await pattern is important—Vue's reactivity system batches updates, so you need to wait for the next tick after triggering events.
User-focused testing with @testing-library/vue
While Vue Test Utils gives you low-level control, @testing-library/vue encourages a different mindset: test what users see and interact with, not implementation details. This approach makes tests more resilient to refactoring.
The library provides utilities like render(), screen.getByRole(), and userEvent that work the same way as @testing-library/react or @testing-library/dom. Instead of finding elements by class or ID, you query by accessible roles.
Let's test a form component using @testing-library/vue:
// SignupForm.vue
<template>
<form @submit.prevent="handleSubmit">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
required
/>
<button type="submit">Sign up</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const email = ref('')
const emit = defineEmits<{ submit: [email: string] }>()
const handleSubmit = () => {
emit('submit', email.value)
email.value = ''
}
</script>The test focuses on user interactions:
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import SignupForm from './SignupForm.vue'
describe('SignupForm.vue', () => {
it('allows user to enter email and submit', async () => {
const { emitted } = render(SignupForm)
const user = userEvent.setup()
const emailInput = screen.getByLabelText('Email')
await user.type(emailInput, 'user@example.com')
const submitButton = screen.getByRole('button', { name: /sign up/i })
await user.click(submitButton)
expect(emitted('submit')).toHaveLength(1)
expect(emitted('submit')[0]).toEqual(['user@example.com'])
})
it('clears email field after submission', async () => {
render(SignupForm)
const user = userEvent.setup()
const emailInput = screen.getByLabelText('Email') as HTMLInputElement
await user.type(emailInput, 'test@example.com')
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(emailInput.value).toBe('')
})
})Notice how we query by label text and button text instead of CSS classes. This keeps tests aligned with how actual users navigate the interface. If you rename a class internally, tests still pass.
Mocking stores with @pinia/testing
Components often depend on Pinia stores. Testing them in isolation means mocking the store so you control its state and verify the component responds correctly.
The @pinia/testing library provides a setActivePinia() call and a test-specific createPinia setup. Here's how it works:
// userStore.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref({ id: 1, name: 'Alice' })
const logout = () => {
user.value = null
}
return { user, logout }
})
// UserProfile.vue
<template>
<div v-if="userStore.user">
<p>{{ userStore.user.name }}</p>
<button @click="handleLogout">Logout</button>
</div>
<div v-else>
<p>Not logged in</p>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/userStore'
const userStore = useUserStore()
const handleLogout = () => {
userStore.logout()
}
</script>Your test sets up a fresh store for each test case:
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
import { useUserStore } from '@/stores/userStore'
describe('UserProfile.vue', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('displays user name when logged in', () => {
const wrapper = mount(UserProfile)
const store = useUserStore()
expect(wrapper.text()).toContain('Alice')
})
it('shows not logged in message when user is null', () => {
const wrapper = mount(UserProfile)
const store = useUserStore()
store.user = null
expect(wrapper.text()).toContain('Not logged in')
})
it('calls logout when logout button is clicked', async () => {
const wrapper = mount(UserProfile)
const store = useUserStore()
const logoutSpy = vi.spyOn(store, 'logout')
await wrapper.find('button').trigger('click')
expect(logoutSpy).toHaveBeenCalled()
})
})setActivePinia(createPinia()) ensures each test gets a fresh store instance with no state leakage between tests. You can then mutate the store directly or spy on its methods. This isolates your component test from store implementation details while still verifying integration.
Choosing jsdom or browser mode
Vitest can run tests in two environments: jsdom (a JavaScript implementation of the DOM) or browser (a real browser via Playwright or WebdriverIO). For most Vue component tests, jsdom is fast enough and sufficient.
Configure jsdom in vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom'
}
})Use browser mode when you need to test CSS animations, WebGL rendering, or browser-specific APIs that jsdom doesn't fully support:
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'browser',
browser: {
provider: 'playwright',
enabled: true
}
}
})Browser mode is slower—your tests run in an actual browser instance—but it's the closest thing to real user behavior. Most projects start with jsdom and switch specific tests to browser mode only when needed. You can also mix both by using vi.setConfig() in individual test files.
Common mistakes
- Not awaiting async updates. After triggering an event or changing a prop, always
awaitbefore asserting. Vue's reactivity system batches updates. - Testing implementation instead of behavior. Avoid checking internal data properties or component methods. Test what the user sees and what gets emitted.
- Over-mocking dependencies. Mock only what you need. If you mock too much, you're no longer testing real behavior. Consider integration tests for complex dependency chains.
- Ignoring accessibility in queries. Using @testing-library/vue's role-based queries forces you to write more accessible HTML and catches a lot of UX issues early.
- Forgetting to reset store state between tests. Always call
setActivePinia(createPinia())inbeforeEach()to prevent test pollution. - Running browser mode for every test. It's tempting, but it's a performance killer. Keep most tests in jsdom and use browser mode surgically.
Key takeaways
- Vue Test Utils
mount()is your foundation for component testing. Use it to render components in isolation and verify their output. - @testing-library/vue encourages testing behavior over implementation. Query by accessible roles and labels, not CSS classes.
- Pinia stores integrate smoothly with tests via
setActivePinia(). Create a fresh store for each test to avoid state leakage. - jsdom is fast and sufficient for most component tests. Use browser mode only for CSS, animations, or browser-specific behavior.
- Always await async operations before asserting. Vue batches updates, so synchronous assertions often fail or give stale results.
- Fast feedback loops matter. Vitest's speed keeps developers in the flow, making it easier to adopt testing as a habit.






Comments
Loading comments…