Testing Vue.js Components with Vue Test Utils

Master component testing for Vue 3 applications. Learn to mount components, simulate user interactions, and write maintainable tests that catch bugs before they reach production.

Why Component Testing Matters

Automated tests help you and your team build complex Vue applications quickly and confidently by preventing regressions and encouraging you to break apart your application into testable functions, modules, and components. As with any application, your new Vue app can break in many ways, and it's important that you can catch these issues and fix them before releasing.

Component tests should catch issues relating to your component's props, events, slots, styles, classes, lifecycle hooks, and more. Unlike unit tests that focus on isolated functions, component tests verify that your UI behaves correctly when users interact with it. This means testing what a component does, not how it does it--focusing on the public interface rather than implementation details.

The key insight from Vue's official guidance is that component tests should not mock child components excessively. Instead, they should test the interactions between your component and its children by interacting with the components as a user would.

When to Start Testing

Start testing early. We recommend you begin writing tests as soon as you can. The longer you wait to add tests to your application, the more dependencies your application will have, and the harder it will be to start. This is particularly true for Vue applications where components often integrate with composables, Pinia stores, and Vue Router.

Setting Up Your Testing Environment

For Vue 3 applications, the recommended approach uses Vitest as your test runner, which integrates seamlessly with Vite-based projects and offers blazing fast execution speeds.

Installing Vue Test Utils and Vitest

To set up component testing in your Vue project, install the necessary packages:

npm install -D @vue/test-utils vitest @vueuse/core

Configuring Vitest for Vue

Create a vitest.config.js file to configure the testing environment:

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
 plugins: [vue()],
 test: {
 environment: 'jsdom',
 globals: true,
 include: ['**/*.spec.{js,ts}', '**/*.test.{js,ts}'],
 coverage: {
 provider: 'v8',
 reporter: ['text', 'json', 'html']
 }
 }
})

The environment: 'jsdom' setting is crucial for component testing as it provides a browser-like DOM environment. The globals: true option allows you to use test functions like describe, test, and expect without importing them.

Our team has successfully implemented these testing patterns across numerous web development projects, ensuring robust test coverage for complex Vue applications.

Mounting Components

The foundation of component testing is mounting--rendering your component in isolation so you can interact with and inspect it. Vue Test Utils provides two primary mounting functions that serve different testing needs.

Using mount() for Full Rendering

The mount() function fully renders your component and all its child components. This is the approach you'll use when you want to test how your component integrates with its children and ensure the entire component tree works correctly.

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

test('renders properly', () => {
 const wrapper = mount(MyComponent, {
 props: {
 title: 'Hello World'
 }
 })

 expect(wrapper.text()).toContain('Hello World')
})

When you mount a component, you can pass options to configure how it renders. The props option allows you to pass component props, while other options like slots, global, and data provide additional configuration for your test setup.

Using shallowMount() for Unit Testing

The shallowMount() function is similar to mount() but replaces child components with stubbed versions. This is particularly useful when you want to isolate the component under test and avoid testing the behavior of its children:

import { shallowMount } from '@vue/test-utils'
import ParentComponent from './ParentComponent.vue'

test('renders only the parent component behavior', () => {
 const wrapper = shallowMount(ParentComponent)

 // Child components are stubbed, so we test parent behavior in isolation
 expect(wrapper.find('.parent-selector').exists()).toBe(true)
})

Mount Options Reference

When mounting components, you have access to several configuration options:

  • props: Component props for passing data
  • slots: Default and named slot content
  • global: Plugins, stubs, and provide/inject configuration
  • data: Initial component data for setting up test state
const wrapper = mount(Component, {
 props: { initialValue: 10, disabled: false },
 slots: {
 default: '<p>Default slot content</p>',
 header: '<h1>Header</h1>'
 },
 global: {
 plugins: [createTestingPinia()],
 stubs: { 'ChildComponent': true },
 provide: { injectedValue: 'provided value' }
 },
 data() { return { localState: 'test' } }
})

Finding Elements and Querying the DOM

Once your component is mounted, you need a way to locate elements within it for interaction and assertion. Vue Test Utils provides several methods for querying the DOM, each suited for different scenarios.

Basic Element Finding

The find() method returns the first matching element within your component, while findAll() returns an array of all matching elements:

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

test('finds elements by various selectors', () => {
 const wrapper = mount(TodoList, {
 data() {
 return { items: ['Task 1', 'Task 2', 'Task 3'] }
 }
 })

 // Find by CSS selector
 const list = wrapper.find('ul.todo-list')

 // Find by data-testid attribute
 const item = wrapper.find('[data-testid="todo-item"]')

 // Find all matching elements
 const allItems = wrapper.findAll('li')
 expect(allItems).toHaveLength(3)
})

Using get() for Assertions

The get() method is similar to find() but is designed specifically for assertions. It throws an error if the element is not found, making your tests more expressive when you expect an element to exist:

test('required elements exist', () => {
 const wrapper = mount(MyForm)

 // Using get() for assertion-style queries
 expect(() => {
 wrapper.get('[data-testid="submit-button"]')
 }).not.toThrow()

 // Shortcut for checking existence
 expect(wrapper.get('[data-testid="submit-button"]').exists()).toBe(true)
})

Finding Components

The findComponent() method locates child components within your mounted component, enabling you to test interactions between parent and child components:

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

test('interacts with child components', () => {
 const wrapper = mount(Parent)

 // Find child component by component reference
 const childComponent = wrapper.findComponent(Child)

 // Interact with child component
 await childComponent.find('button').trigger('click')

 // Assert parent responded to child event
 expect(wrapper.emitted('parent-event')).toBeTruthy()
})

Testing Props and Events

Components receive data through props and communicate upward through events. Your tests should verify that components handle various prop values correctly and emit expected events with correct payloads.

Passing Props to Components

Pass props to your component through the mount options:

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

test('renders user information from props', () => {
 const wrapper = mount(UserCard, {
 props: {
 user: {
 name: 'John Doe',
 email: '[email protected]'
 }
 }
 })

 expect(wrapper.find('.user-name').text()).toBe('John Doe')
 expect(wrapper.find('.user-email').text()).toBe('[email protected]')
})

Testing Prop Reactivity

Test that your component responds correctly when props update:

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

test('reacts to prop changes', async () => {
 const wrapper = mount(Counter, {
 props: {
 initialCount: 0
 }
 })

 expect(wrapper.text()).toContain('Count: 0')

 // Update props
 await wrapper.setProps({ initialCount: 5 })

 expect(wrapper.text()).toContain('Count: 5')
})

Asserting Emitted Events

Vue Test Utils tracks all events emitted by your component through the emitted() method:

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

test('emits events correctly', () => {
 const wrapper = mount(EmitterComponent)

 // Interact with component
 wrapper.find('button').trigger('click')

 // Check that event was emitted
 expect(wrapper.emitted()).toHaveProperty('increment')
 expect(wrapper.emitted('increment')).toHaveLength(1)

 // Check event payload
 expect(wrapper.emitted('increment')[0]).toEqual([1])

 // Trigger again and check
 wrapper.find('button').trigger('click')
 expect(wrapper.emitted('increment')).toHaveLength(2)
})

Testing v-model Components

Testing components that use v-model requires understanding how Vue handles two-way binding:

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

test('v-model two-way binding', async () => {
 const wrapper = mount(InputComponent, {
 props: {
 modelValue: 'initial',
 'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
 }
 })

 const input = wrapper.find('input')

 // Simulate user input
 await input.setValue('updated value')

 // Check that event was emitted for v-model
 expect(wrapper.emitted('update:modelValue')).toBeTruthy()
 expect(wrapper.emitted('update:modelValue')[0]).toEqual(['updated value'])
})

Testing User Interactions

User interactions are at the heart of what makes your application interactive. Vue Test Utils provides the trigger() method to simulate these interactions and verify that components respond correctly.

Simulating Clicks and Basic Events

The trigger() method simulates DOM events on elements:

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

test('toggles like state on click', async () => {
 const wrapper = mount(LikeButton)

 expect(wrapper.text()).toContain('Like')
 expect(wrapper.classes('liked')).toBe(false)

 // Simulate click
 await wrapper.find('button').trigger('click')

 expect(wrapper.text()).toContain('Liked')
 expect(wrapper.classes('liked')).toBe(true)

 // Click again to unlike
 await wrapper.find('button').trigger('click')
 expect(wrapper.classes('liked')).toBe(false)
})

Testing Form Interactions

Forms require special attention since they involve multiple types of interactions:

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

test('form validation and submission', async () => {
 const wrapper = mount(ContactForm)

 // Fill in text input
 await wrapper.find('input[name="name"]').setValue('John Doe')

 // Fill in email input
 await wrapper.find('input[name="email"]').setValue('[email protected]')

 // Check a checkbox
 await wrapper.find('input[name="newsletter"]').setChecked(true)

 // Select from dropdown
 await wrapper.find('select[name="subject"]').setValue('support')

 // Submit form
 await wrapper.find('form').trigger('submit.prevent')

 // Assert form submitted with correct data
 expect(wrapper.emitted('submit')).toBeTruthy()
 expect(wrapper.emitted('submit')[0][0]).toEqual({
 name: 'John Doe',
 email: '[email protected]',
 newsletter: true,
 subject: 'support'
 })
})

Handling Async Interactions

Many modern applications involve asynchronous operations--whether fetching data, submitting forms to APIs, or processing results. Testing these patterns requires proper async handling:

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

test('handles async data loading', async () => {
 const wrapper = mount(AsyncDataComponent)

 // Initially shows loading state
 expect(wrapper.text()).toContain('Loading...')

 // Wait for async operation to complete
 await flushPromises()

 // After data loads
 expect(wrapper.text()).not.toContain('Loading...')
 expect(wrapper.findAll('.data-item')).toHaveLength(3)
})

Testing Slots and Async Behavior

Vue's slot system allows components to receive content from their parents, and Vue 3's Suspense component and async setup functions require special handling in tests.

Testing Default and Named Slots

Pass slot content through the mount options:

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

test('renders default and named slots', () => {
 const wrapper = mount(CardComponent, {
 slots: {
 default: '<p>Default slot content</p>',
 header: '<h1>Card Title</h1>',
 footer: '<button>Action</button>'
 }
 })

 expect(wrapper.html()).toContain('Card Title')
 expect(wrapper.html()).toContain('Default slot content')
 expect(wrapper.html()).toContain('Action')
})

Testing Scoped Slots

Scoped slots receive data from the child component. Testing them requires understanding how the data flows:

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

test('scoped slot receives data', () => {
 const wrapper = mount(ListWrapper, {
 slots: {
 default: `<template #default="{ items, addItem }">
 <div class="custom-list">
 <span v-for="item in items">{{ item.name }}</span>
 <button @click="addItem">Add</button>
 </div>
 </template>`
 }
 })

 expect(wrapper.find('.custom-list').exists()).toBe(true)
 expect(wrapper.findAll('.custom-list span')).toHaveLength(3)
})

Testing Components with Async Setup

Components with async setup need special handling:

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

test('component with async setup', async () => {
 const wrapper = mount(AsyncSetupComponent)

 // Wait for async setup to complete
 await flushPromises()

 // Now component is fully rendered
 expect(wrapper.text()).toContain('Data loaded')
 expect(wrapper.find('.async-content').exists()).toBe(true)
})

Testing with Suspense

Test components within Suspense boundaries:

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

test('components within Suspense', async () => {
 const wrapper = mount({
 template: `
 <Suspense>
 <template #default>
 <AsyncComponent />
 </template>
 <template #fallback>
 <div>Loading...</div>
 </template>
 </Suspense>
 `
 })

 // Initially shows loading state
 expect(wrapper.text()).toContain('Loading...')

 // Wait for async component
 await flushPromises()

 // Component loaded
 expect(wrapper.text()).not.toContain('Loading...')
 expect(wrapper.find('.async-result').exists()).toBe(true)
})

Best Practices for Maintainable Tests

As your test suite grows, maintainability becomes crucial. Following these patterns will keep your tests robust and easy to update.

Test Behavior, Not Implementation

The most important principle for maintainable tests is to test behavior, not implementation. Your tests should focus on the component's public interface--what it renders and what events it emits--not on how it achieves that internally:

// DON'T - Testing implementation details
test('updates internal counter state', async () => {
 const wrapper = mount(CounterComponent)
 expect(wrapper.vm.counter).toBe(0) // Testing internal state
 await wrapper.find('button').trigger('click')
 expect(wrapper.vm.counter).toBe(1) // Brittle - breaks on refactor
})

// DO - Testing user-visible behavior
test('displays incremented count after click', async () => {
 const wrapper = mount(CounterComponent)
 expect(wrapper.text()).toContain('Count: 0')
 await wrapper.find('button').trigger('click')
 expect(wrapper.text()).toContain('Count: 1') // Tests what users see
})

Use Data-Test Attributes Selectively

While CSS classes and IDs work for selection, adding data-testid attributes provides stable selectors that don't change when you refactor styles:

// Use data-testid for reliable selection
const wrapper = mount(FormComponent)

// This won't break when you change CSS classes
const submitButton = wrapper.find('[data-testid="submit-button"]')
const errorMessage = wrapper.find('[data-testid="error-message"]')

Organize Tests Logically

Group related tests using describe blocks and use clear test names:

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

describe('UserProfile', () => {
 // Basic rendering
 describe('rendering', () => {
 test('renders user name when user prop is provided', () => {})
 test('shows placeholder when no user is provided', () => {})
 })

 // User interactions
 describe('user interactions', () => {
 test('emits edit event when edit button is clicked', () => {})
 })

 // Edge cases
 describe('edge cases', () => {
 test('handles long user names gracefully', () => {})
 })
})

Mock External Dependencies

External dependencies like APIs should be mocked to keep tests fast and reliable:

import { mount } from '@vue/test-utils'
import { vi } from 'vitest'
import UserList from './UserList.vue'

test('displays users from API', async () => {
 // Mock the API call
 const mockUsers = [
 { id: 1, name: 'Alice' },
 { id: 2, name: 'Bob' }
 ]

 // Use vi.mock or inject a mock
 const wrapper = mount(UserList, {
 global: {
 provide: {
 fetchUsers: vi.fn().mockResolvedValue(mockUsers)
 }
 }
 })

 // Tests run instantly without real API calls
 await flushPromises()
 expect(wrapper.text()).toContain('Alice')
 expect(wrapper.text()).toContain('Bob')
})

Common Testing Patterns

Testing Conditional Rendering

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

test('toggles visibility based on state', async () => {
 const wrapper = mount(ConditionalComponent)

 expect(wrapper.find('.hidden-content').exists()).toBe(false)

 await wrapper.find('[data-testid="toggle"]').trigger('click')

 expect(wrapper.find('.hidden-content').exists()).toBe(true)
})

Testing Computed Properties

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

test('calculates correct total price', () => {
 const wrapper = mount(PriceCalculator, {
 props: { basePrice: 100, quantity: 2 }
 })

 expect(wrapper.text()).toContain('Total: $200')
})

Testing Watchers and Lifecycle Hooks

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

test('initializes data on mount', () => {
 const wrapper = mount(LifecycleComponent)
 expect(wrapper.vm.initialized).toBe(true)
})

test('cleans up on unmount', () => {
 const wrapper = mount(LifecycleComponent)
 const cleanupSpy = vi.spyOn(wrapper.vm, 'cleanup')

 wrapper.unmount()

 expect(cleanupSpy).toHaveBeenCalled()
})

Frequently Asked Questions

What's the difference between mount() and shallowMount()?

mount() fully renders your component and all child components, while shallowMount() replaces child components with stubs. Use shallowMount() when you want to test a component in isolation, and mount() when you need to test integration with children.

Should I mock child components?

Generally no. Component tests should test interactions between components as a user would. However, use stubs when child components have external dependencies, complex async behavior, or would make tests too slow.

How do I test components with Vue Router?

Use Vue Router's createRouterMock() or provide a mock router instance through the global configuration. This allows you to test navigation and route-related behavior without full routing.

What's the best way to test Pinia stores?

Create a testing instance of Pinia using createTestingPinia() and provide it through the global configuration. This allows you to set initial state and verify state changes in your component tests.

How do I handle HTTP requests in tests?

Mock the HTTP client (like axios or fetch) using tools like vi.mock(). Return mock data to keep tests fast and reliable. Never make real API calls in your test suite.

What test coverage should I aim for?

Focus on critical paths and user-facing behavior rather than a specific percentage. Cover edge cases and error states. 100% coverage isn't necessary--confidence in your application's correctness is the goal.

Conclusion

Vue Test Utils provides a comprehensive toolkit for testing Vue components effectively. By following these patterns--testing behavior over implementation, using proper mount options, and structuring tests for maintainability--you can build a test suite that gives confidence in your application's correctness while remaining resilient to refactoring.

Start with simple tests for critical components, expand coverage incrementally, and remember that the goal is not 100% coverage but rather confidence that your application works as users expect. With Vue Test Utils and Vitest, you have the official tools and modern infrastructure to make component testing an integral part of your development workflow.

For teams building complex Vue applications, investing in a robust testing strategy pays dividends in reduced bugs, faster feedback loops, and the confidence to refactor without fear. Whether you're building interactive dashboards, data-intensive applications, or consumer-facing interfaces, comprehensive component testing ensures your Vue applications deliver reliable experiences to users.

Need help implementing a comprehensive testing strategy for your Vue.js project? Our web development team specializes in building testable applications with comprehensive test coverage. We also help teams integrate AI-powered testing solutions through our AI automation services to streamline development workflows and ensure quality at scale.


Sources

  1. Vue Test Utils Official Guide
  2. Vue.js Official Testing Guide
  3. Vue Test Utils API Reference

Need Help with Vue.js Testing?

Our team specializes in building testable Vue applications with comprehensive test coverage. From setting up Vitest environments to implementing end-to-end testing strategies, we help development teams ship with confidence.